Compare commits
1 Commits
bad780de9d
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
072a7c0b56
|
+1
-1
@@ -27,6 +27,6 @@ Thumbs.db
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Personal scratch notes (tracked docs live in docs/ and are NOT ignored)
|
||||
# Personal scratch notes (the engine documentation lives on the wiki, not here)
|
||||
*.local.md
|
||||
/scratch/
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Changelog
|
||||
|
||||
Kept as the work happens, not assembled at release time.
|
||||
|
||||
Oxide is in its **alpha phase** — `0.x.x`, on `main`. Phase 1 runs to Stage 16
|
||||
(game export to Linux and Windows), and that milestone is what `1.0.0` means.
|
||||
Until then there is no `dev` channel: alpha *is* main.
|
||||
|
||||
The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 0.1.0 — 2026-08-08
|
||||
|
||||
The first tagged version. It is not the first work: Stages 0–9 are complete and
|
||||
Stage 10 (scripting, live reload, editor terminal) is complete on `main` bar the
|
||||
editor-UX batch on `dev`. This entry marks the point the project started being
|
||||
versioned, not the point it started.
|
||||
|
||||
### Added
|
||||
|
||||
- Stages 0–4: workspace and build, math and core primitives, windowing and the
|
||||
app loop, the render context, the scene graph, and forward rendering.
|
||||
- Stage 5, the engine core framework: modules and scheduling, layers/tags with
|
||||
the shared `LayerMask`, the reflection/type registry, the asset server, and
|
||||
the data-driven render pass pipeline.
|
||||
- Stage 6, the editor framework: docking shell, project system, settings
|
||||
framework, file watching, and the module → editor extension API.
|
||||
- Stage 7: the input system — `InputState`, named remappable actions, axes.
|
||||
- Stage 8: the in-game UI system (widgets, layout, theming, text, routing), with
|
||||
the GUI tail still open. Stage 8.5 prefabs and 8.7 editor play mode.
|
||||
- Stage 9: physics on rapier3d, with `LayerMask`-filtered collision.
|
||||
- Stage 10: scripting on rhai — the `Script` component, the sandboxed engine,
|
||||
live reload, the editor console and PTY terminal, and the richer script API.
|
||||
|
||||
### Changed
|
||||
|
||||
- The documentation left the repository. `docs/`, `PLAN.md`, `HANDOFF.md` and
|
||||
`CLAUDE.md` are now the [wiki](https://git.houmeres.sk/Houmeres/Oxide/wiki),
|
||||
and the repository's history was rewritten in the same pass so they are not
|
||||
present at any commit. `README.md` and this file stay here, because they ship
|
||||
with the software.
|
||||
- Doc comments and `README.md` that pointed at those files now point at the
|
||||
wiki.
|
||||
|
||||
### Note on history
|
||||
|
||||
Any clone of this repository taken before 2026-08-08 has a different history and
|
||||
should be discarded rather than merged.
|
||||
+1
-5
@@ -28,7 +28,7 @@ hecs = "0.10"
|
||||
# Linux is the primary target on BOTH Wayland and Xorg (X11): keep both winit
|
||||
# backends explicitly enabled so neither can be dropped by a default-feature
|
||||
# change. (On by default today; listing them makes the contract explicit —
|
||||
# see PLAN.md "Platform & Target Strategy".)
|
||||
# see the wiki Roadmap, "Platform & Target Strategy".)
|
||||
winit = { version = "0.30", features = ["x11", "wayland", "serde"] }
|
||||
wgpu = "29"
|
||||
pollster = "0.4"
|
||||
@@ -45,10 +45,6 @@ gltf = { version = "1.4", features = ["utils"] }
|
||||
ab_glyph = "0.2"
|
||||
|
||||
# Editor UI (egui — integrated into oxide-editor only)
|
||||
# Native file/folder dialogs (New/Open Project). The default `xdg-portal`
|
||||
# backend is pure Rust and talks to xdg-desktop-portal over D-Bus, so one
|
||||
# build serves both Wayland and X11 with no GTK link-time dependency.
|
||||
rfd = "0.15"
|
||||
egui = "0.34"
|
||||
egui-wgpu = "0.34"
|
||||
egui-winit = "0.34"
|
||||
|
||||
@@ -13,7 +13,7 @@ stylized low-poly to realistic graphics, and shipping only what each game uses.
|
||||
|
||||
## Status
|
||||
|
||||
Oxide is built in two phases (see [PLAN.md](PLAN.md)): **Phase 1 (Stages 0–16)**
|
||||
Oxide is built in two phases (see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap)): **Phase 1 (Stages 0–16)**
|
||||
is the general-purpose engine — everything needed to build and export any game;
|
||||
**Phase 2 (Stages 17+)** adds optional, feature-gated built-in modules.
|
||||
|
||||
@@ -74,7 +74,7 @@ Available now:
|
||||
Runnable example: `cargo run -p oxide-examples --bin ui_menu` (Stage-8
|
||||
piece 7)
|
||||
|
||||
## Features (planned — see [PLAN.md](PLAN.md))
|
||||
## Features (planned — see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap))
|
||||
|
||||
**Phase 1 — the general-purpose engine:**
|
||||
|
||||
@@ -117,7 +117,7 @@ Available now:
|
||||
|
||||
- Rust stable toolchain (`rustup` recommended)
|
||||
- Linux — primary target, on **both Wayland and Xorg (X11)**. Windows support and cross-platform
|
||||
game export are planned (see [PLAN.md](PLAN.md), Stage 16); other platforms are not yet tested.
|
||||
game export are planned (see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap), Stage 16); other platforms are not yet tested.
|
||||
- A GPU with Vulkan or Metal support (for `wgpu`)
|
||||
|
||||
Install Rust if you don't have it:
|
||||
@@ -208,18 +208,23 @@ cargo fmt --check
|
||||
## Documentation
|
||||
|
||||
The full documentation — usage guides, per-system API references, and
|
||||
explanations of the engine's inner workings — lives in [`docs/`](docs/README.md).
|
||||
Start there for anything beyond this overview:
|
||||
explanations of the engine's inner workings — lives on the
|
||||
[wiki](https://git.houmeres.sk/Houmeres/Oxide/wiki). Start there for anything
|
||||
beyond this overview:
|
||||
|
||||
- [Getting Started](docs/getting-started.md) — build, run, test, install
|
||||
- [Architecture](docs/architecture.md) — workspace and design overview
|
||||
- [Conventions](docs/conventions.md) — coordinate system, units, color space
|
||||
- [Development Workflow](docs/development.md) — branches, testing, contributing
|
||||
- [Math & Core Primitives](docs/math.md) — Stage 1 API reference
|
||||
- [Windowing & App Loop](docs/windowing.md) — Stage 2 window/event-loop reference
|
||||
- [Render Context](docs/render-context.md) — Stage 2 GPU/surface reference
|
||||
- [Scene Graph & Entities](docs/scene.md) — Stage 3 scene/hierarchy/serialization reference
|
||||
- [Rendering](docs/rendering.md) — Stage 4 mesh/material/camera/forward-renderer reference
|
||||
- [Getting Started](https://git.houmeres.sk/Houmeres/Oxide/wiki/Getting-started) — build, run, test, install
|
||||
- [Architecture](https://git.houmeres.sk/Houmeres/Oxide/wiki/Architecture) — workspace and design overview
|
||||
- [Conventions](https://git.houmeres.sk/Houmeres/Oxide/wiki/Conventions) — coordinate system, units, color space
|
||||
- [Development Workflow](https://git.houmeres.sk/Houmeres/Oxide/wiki/Development) — branches, testing, contributing
|
||||
- [Math & Core Primitives](https://git.houmeres.sk/Houmeres/Oxide/wiki/Math) — Stage 1 API reference
|
||||
- [Windowing & App Loop](https://git.houmeres.sk/Houmeres/Oxide/wiki/Windowing) — Stage 2 window/event-loop reference
|
||||
- [Render Context](https://git.houmeres.sk/Houmeres/Oxide/wiki/Render-context) — Stage 2 GPU/surface reference
|
||||
- [Scene Graph & Entities](https://git.houmeres.sk/Houmeres/Oxide/wiki/Scene) — Stage 3 scene/hierarchy/serialization reference
|
||||
- [Rendering](https://git.houmeres.sk/Houmeres/Oxide/wiki/Rendering) — Stage 4 mesh/material/camera/forward-renderer reference
|
||||
|
||||
The [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap) is the staged
|
||||
plan, and [Working notes](https://git.houmeres.sk/Houmeres/Oxide/wiki/Working-notes)
|
||||
holds the project's rules. Both used to be files in this repository.
|
||||
|
||||
---
|
||||
|
||||
@@ -228,14 +233,11 @@ Start there for anything beyond this overview:
|
||||
```
|
||||
Oxide/
|
||||
├── assets/ # Project logos and shared assets
|
||||
├── docs/ # Full engine documentation
|
||||
├── engine/ # Core engine library (oxide-engine)
|
||||
├── editor/ # In-engine editor binary (oxide-editor)
|
||||
├── examples/ # Runnable stage examples (oxide-examples)
|
||||
├── tests/ # Integration test harness (oxide-tests)
|
||||
├── install.sh # Build + system install script
|
||||
├── PLAN.md # Staged development roadmap
|
||||
└── CLAUDE.md # Context and rules for Claude Code
|
||||
└── install.sh # Build + system install script
|
||||
```
|
||||
|
||||
---
|
||||
@@ -243,7 +245,7 @@ Oxide/
|
||||
## Development roadmap
|
||||
|
||||
Development follows a staged plan — each stage is fully tested before the next begins.
|
||||
See [PLAN.md](PLAN.md) for the complete roadmap.
|
||||
See the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap) for the complete plan.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,15 +22,9 @@ egui.workspace = true
|
||||
egui-wgpu.workspace = true
|
||||
egui-winit.workspace = true
|
||||
egui_dock.workspace = true
|
||||
# Native folder picker for New/Open Project, run on a helper thread so the
|
||||
# UI keeps redrawing while the dialog is up (see Shell::poll_folder_pick).
|
||||
rfd.workspace = true
|
||||
# Editor preferences file I/O reads/writes the same RON shape `Settings`
|
||||
# exports; the engine already pulls `ron` in, the editor now does too.
|
||||
ron.workspace = true
|
||||
# Editor-owned settings sections (e.g. the External Editor preference) derive
|
||||
# their own Serialize/Deserialize for the Settings store.
|
||||
serde.workspace = true
|
||||
|
||||
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
|
||||
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
|
||||
|
||||
@@ -96,7 +96,7 @@ pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result<bool> {
|
||||
/// 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
|
||||
/// Kept to the two lifecycle hooks the wiki's Scripting page 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 {
|
||||
|
||||
@@ -1,524 +0,0 @@
|
||||
//! The Project panel's file explorer — state and file/database operations.
|
||||
//!
|
||||
//! Stage-10 editor-UX: a Unity-style explorer over the project's `assets/`
|
||||
//! tree. This module holds everything that does **not** touch egui — the
|
||||
//! navigation state, directory listing, and the create/rename/move/delete/
|
||||
//! import operations — so the whole behavior layer is unit-testable and the
|
||||
//! shell only renders it (`ShellTabViewer::assets_explorer`).
|
||||
//!
|
||||
//! Every operation goes through the [`AssetDatabase`] file ops
|
||||
//! (`move_asset`/`move_folder`/`delete_asset`) whenever the touched file is
|
||||
//! registered, so an asset keeps its [`AssetUid`] — and every saved
|
||||
//! `AssetRef` keeps resolving — across any reorganisation. Files the
|
||||
//! database does not know (licenses, notes, …) fall back to plain
|
||||
//! filesystem operations.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use oxide_engine::asset::{AssetDatabase, AssetDbError, AssetKind, AssetUid};
|
||||
|
||||
/// The explorer's persistent UI state (lives on the `Shell`, survives frames).
|
||||
#[derive(Default)]
|
||||
pub struct ExplorerState {
|
||||
/// The folder being viewed, relative to `assets/` (`""` = the root).
|
||||
pub cwd: String,
|
||||
/// An in-progress rename, if any.
|
||||
pub rename: Option<RenameEdit>,
|
||||
/// The in-progress "New Folder" name, `Some` while the inline row shows.
|
||||
pub new_folder: Option<String>,
|
||||
/// One-shot: the next inline text field rendered requests focus (set when
|
||||
/// a rename / new-folder edit starts, taken by the first frame).
|
||||
pub focus_field: bool,
|
||||
}
|
||||
|
||||
impl ExplorerState {
|
||||
/// Navigates to `cwd`, dropping any in-progress inline edits.
|
||||
pub fn navigate(&mut self, cwd: impl Into<String>) {
|
||||
self.cwd = cwd.into();
|
||||
self.rename = None;
|
||||
self.new_folder = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-progress rename of one entry: what is being renamed + the buffer.
|
||||
pub struct RenameEdit {
|
||||
/// The entry's current assets-relative path.
|
||||
pub rel: String,
|
||||
/// Whether it is a folder.
|
||||
pub is_dir: bool,
|
||||
/// The name being typed.
|
||||
pub buf: String,
|
||||
}
|
||||
|
||||
/// One row of the explorer listing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
/// The leaf name shown in the panel.
|
||||
pub name: String,
|
||||
/// Assets-relative path (forward slashes).
|
||||
pub rel: String,
|
||||
/// Whether this is a folder.
|
||||
pub is_dir: bool,
|
||||
/// The database uid, when the file is registered.
|
||||
pub uid: Option<AssetUid>,
|
||||
/// The registered kind, when the file is registered.
|
||||
pub kind: Option<AssetKind>,
|
||||
}
|
||||
|
||||
/// Lists the folder `cwd` (relative to `assets/`): folders first, then files,
|
||||
/// each group sorted by name. Files are annotated with their database
|
||||
/// uid/kind when registered. A missing folder yields an empty list (the
|
||||
/// assets root may not exist yet in a fresh project).
|
||||
pub fn list_dir(db: &AssetDatabase, cwd: &str) -> Vec<Entry> {
|
||||
let dir = abs_of(db, cwd);
|
||||
let Ok(read) = std::fs::read_dir(&dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut folders: Vec<Entry> = Vec::new();
|
||||
let mut files: Vec<Entry> = Vec::new();
|
||||
for item in read.flatten() {
|
||||
let name = item.file_name().to_string_lossy().into_owned();
|
||||
let rel = join_rel(cwd, &name);
|
||||
if item.path().is_dir() {
|
||||
folders.push(Entry {
|
||||
name,
|
||||
rel,
|
||||
is_dir: true,
|
||||
uid: None,
|
||||
kind: None,
|
||||
});
|
||||
} else {
|
||||
let uid = db.uid_of(&rel);
|
||||
let kind = uid.and_then(|u| db.entry(u)).map(|e| e.kind);
|
||||
files.push(Entry {
|
||||
name,
|
||||
rel,
|
||||
is_dir: false,
|
||||
uid,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
folders.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
files.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
folders.extend(files);
|
||||
folders
|
||||
}
|
||||
|
||||
/// The breadcrumb trail for `cwd`: `(label, cwd-to-navigate-to)` pairs,
|
||||
/// starting at the assets root. `"textures/env"` yields
|
||||
/// `[("assets",""), ("textures","textures"), ("env","textures/env")]`.
|
||||
pub fn breadcrumbs(cwd: &str) -> Vec<(String, String)> {
|
||||
let mut crumbs = vec![("assets".to_owned(), String::new())];
|
||||
let mut path = String::new();
|
||||
for seg in cwd.split('/').filter(|s| !s.is_empty()) {
|
||||
path = join_rel(&path, seg);
|
||||
crumbs.push((seg.to_owned(), path.clone()));
|
||||
}
|
||||
crumbs
|
||||
}
|
||||
|
||||
/// Joins a folder path and a leaf name into an assets-relative path.
|
||||
pub fn join_rel(dir: &str, name: &str) -> String {
|
||||
if dir.is_empty() {
|
||||
name.to_owned()
|
||||
} else {
|
||||
format!("{dir}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent folder of an assets-relative path (`""` at the top).
|
||||
pub fn parent_of(rel: &str) -> String {
|
||||
rel.rsplit_once('/')
|
||||
.map(|(p, _)| p.to_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether `name` is usable as a single new file/folder name: non-empty and
|
||||
/// free of path separators / traversal.
|
||||
pub fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\')
|
||||
}
|
||||
|
||||
/// A name that does not exist in `dir` yet, derived from `wanted` by
|
||||
/// suffixing `_2`, `_3`, … before the extension (`wall.png` → `wall_2.png`).
|
||||
pub fn unique_name(dir: &Path, wanted: &str) -> String {
|
||||
if !dir.join(wanted).exists() {
|
||||
return wanted.to_owned();
|
||||
}
|
||||
let (stem, ext) = match wanted.rsplit_once('.') {
|
||||
// A leading dot (".gitignore") is a hidden name, not an extension.
|
||||
Some((s, e)) if !s.is_empty() => (s, Some(e)),
|
||||
_ => (wanted, None),
|
||||
};
|
||||
let mut n = 2;
|
||||
loop {
|
||||
let candidate = match ext {
|
||||
Some(ext) => format!("{stem}_{n}.{ext}"),
|
||||
None => format!("{stem}_{n}"),
|
||||
};
|
||||
if !dir.join(&candidate).exists() {
|
||||
return candidate;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a new folder in `cwd` named `wanted` (unique-ified), returning its
|
||||
/// assets-relative path.
|
||||
pub fn create_folder(db: &AssetDatabase, cwd: &str, wanted: &str) -> std::io::Result<String> {
|
||||
if !valid_name(wanted) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("invalid folder name: {wanted:?}"),
|
||||
));
|
||||
}
|
||||
let dir = abs_of(db, cwd);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let name = unique_name(&dir, wanted);
|
||||
std::fs::create_dir(dir.join(&name))?;
|
||||
Ok(join_rel(cwd, &name))
|
||||
}
|
||||
|
||||
/// Renames the entry at `rel` to `new_name` (same folder), returning the new
|
||||
/// relative path. Registered files keep their uid via
|
||||
/// [`AssetDatabase::move_asset`]; folders move every registered entry under
|
||||
/// them via [`AssetDatabase::move_folder`]; unregistered files fall back to a
|
||||
/// plain `fs::rename` (refusing to overwrite).
|
||||
pub fn rename_entry(
|
||||
db: &mut AssetDatabase,
|
||||
rel: &str,
|
||||
is_dir: bool,
|
||||
new_name: &str,
|
||||
) -> Result<String, AssetDbError> {
|
||||
if !valid_name(new_name) {
|
||||
return Err(AssetDbError::InvalidPath(new_name.to_owned()));
|
||||
}
|
||||
let new_rel = join_rel(&parent_of(rel), new_name);
|
||||
if new_rel == rel {
|
||||
return Ok(new_rel);
|
||||
}
|
||||
move_to(db, rel, is_dir, &new_rel)?;
|
||||
Ok(new_rel)
|
||||
}
|
||||
|
||||
/// Moves the entry at `rel` into the folder `dest_dir`, returning the new
|
||||
/// relative path. Same uid-preserving rules as [`rename_entry`].
|
||||
pub fn move_entry(
|
||||
db: &mut AssetDatabase,
|
||||
rel: &str,
|
||||
is_dir: bool,
|
||||
dest_dir: &str,
|
||||
) -> Result<String, AssetDbError> {
|
||||
let name = rel.rsplit('/').next().unwrap_or(rel);
|
||||
let new_rel = join_rel(dest_dir, name);
|
||||
if new_rel == rel {
|
||||
return Ok(new_rel);
|
||||
}
|
||||
move_to(db, rel, is_dir, &new_rel)?;
|
||||
Ok(new_rel)
|
||||
}
|
||||
|
||||
/// Deletes the entry: registered files through the database (entry dropped,
|
||||
/// uid retired), unregistered files from disk, and folders **only when
|
||||
/// empty** — recursive delete of assets is deliberately not offered.
|
||||
pub fn delete_entry(db: &mut AssetDatabase, entry: &Entry) -> Result<(), AssetDbError> {
|
||||
if entry.is_dir {
|
||||
let dir = abs_of(db, &entry.rel);
|
||||
if std::fs::read_dir(&dir)?.next().is_some() {
|
||||
// (`ErrorKind::DirectoryNotEmpty` needs Rust 1.83; the workspace
|
||||
// MSRV is older, so this stays a generic I/O error.)
|
||||
return Err(AssetDbError::Io(std::io::Error::other(format!(
|
||||
"folder not empty: {} (delete its contents first)",
|
||||
entry.rel
|
||||
))));
|
||||
}
|
||||
std::fs::remove_dir(&dir)?;
|
||||
return Ok(());
|
||||
}
|
||||
match entry.uid {
|
||||
Some(uid) => delete_and_save(db, uid),
|
||||
None => Ok(std::fs::remove_file(abs_of(db, &entry.rel))?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports files dropped from the OS into `cwd`: each is copied in under a
|
||||
/// collision-free name and registered (kind from the folder, else extension).
|
||||
/// Directories and unreadable sources are skipped with a log line. Returns
|
||||
/// how many files were imported.
|
||||
pub fn import_files(db: &mut AssetDatabase, cwd: &str, sources: &[PathBuf]) -> usize {
|
||||
let dir = abs_of(db, cwd);
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return 0;
|
||||
}
|
||||
let mut imported = 0;
|
||||
for src in sources {
|
||||
if src.is_dir() {
|
||||
log::warn!("skipping folder drop {} (import files)", src.display());
|
||||
continue;
|
||||
}
|
||||
let Some(file_name) = src.file_name().map(|n| n.to_string_lossy().into_owned()) else {
|
||||
continue;
|
||||
};
|
||||
let name = unique_name(&dir, &file_name);
|
||||
match std::fs::copy(src, dir.join(&name)) {
|
||||
Ok(_) => {
|
||||
let rel = join_rel(cwd, &name);
|
||||
db.register(&rel);
|
||||
log::info!("imported {rel}");
|
||||
imported += 1;
|
||||
}
|
||||
Err(err) => log::warn!("could not import {}: {err}", src.display()),
|
||||
}
|
||||
}
|
||||
if imported > 0 {
|
||||
if let Err(err) = db.save() {
|
||||
log::warn!("could not write asset manifest: {err}");
|
||||
}
|
||||
}
|
||||
imported
|
||||
}
|
||||
|
||||
// --- internals -----------------------------------------------------------
|
||||
|
||||
/// The absolute path of an assets-relative path under the database's root.
|
||||
fn abs_of(db: &AssetDatabase, rel: &str) -> PathBuf {
|
||||
let assets = db.assets_dir();
|
||||
if rel.is_empty() {
|
||||
assets
|
||||
} else {
|
||||
assets.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes a rename/move to the right primitive: `move_folder` for folders,
|
||||
/// `move_asset` for registered files, `fs::rename` (no overwrite) for
|
||||
/// unregistered ones. Saves the manifest after a database change.
|
||||
fn move_to(
|
||||
db: &mut AssetDatabase,
|
||||
rel: &str,
|
||||
is_dir: bool,
|
||||
new_rel: &str,
|
||||
) -> Result<(), AssetDbError> {
|
||||
if is_dir {
|
||||
db.move_folder(rel, new_rel)?;
|
||||
save_manifest(db);
|
||||
return Ok(());
|
||||
}
|
||||
match db.uid_of(rel) {
|
||||
Some(uid) => {
|
||||
db.move_asset(uid, new_rel)?;
|
||||
save_manifest(db);
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
let to = abs_of(db, new_rel);
|
||||
if to.exists() {
|
||||
return Err(AssetDbError::DestinationExists(new_rel.to_owned()));
|
||||
}
|
||||
if let Some(parent) = to.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
Ok(std::fs::rename(abs_of(db, rel), to)?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a registered asset and persists the manifest.
|
||||
fn delete_and_save(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), AssetDbError> {
|
||||
db.delete_asset(uid)?;
|
||||
save_manifest(db);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Best-effort manifest save after a mutation (failure → Console, not fatal).
|
||||
fn save_manifest(db: &AssetDatabase) {
|
||||
if let Err(err) = db.save() {
|
||||
log::warn!("could not write asset manifest: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// A fresh project root with an `assets/` tree and an open database.
|
||||
fn scratch_db(files: &[&str]) -> (PathBuf, AssetDatabase) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"oxide_explorer_test_{}_{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::SeqCst),
|
||||
));
|
||||
let assets = root.join("assets");
|
||||
std::fs::create_dir_all(&assets).unwrap();
|
||||
for rel in files {
|
||||
let full = assets.join(rel);
|
||||
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
|
||||
std::fs::write(full, b"x").unwrap();
|
||||
}
|
||||
let mut db = AssetDatabase::new(&root);
|
||||
db.scan();
|
||||
(root, db)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breadcrumbs_and_path_helpers() {
|
||||
assert_eq!(breadcrumbs(""), vec![("assets".to_owned(), String::new())]);
|
||||
assert_eq!(
|
||||
breadcrumbs("textures/env"),
|
||||
vec![
|
||||
("assets".to_owned(), String::new()),
|
||||
("textures".to_owned(), "textures".to_owned()),
|
||||
("env".to_owned(), "textures/env".to_owned()),
|
||||
]
|
||||
);
|
||||
assert_eq!(join_rel("", "a"), "a");
|
||||
assert_eq!(join_rel("a/b", "c"), "a/b/c");
|
||||
assert_eq!(parent_of("a/b/c"), "a/b");
|
||||
assert_eq!(parent_of("a"), "");
|
||||
assert!(valid_name("wall.png"));
|
||||
assert!(!valid_name(""));
|
||||
assert!(!valid_name("a/b"));
|
||||
assert!(!valid_name(".."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_dir_sorts_folders_first_and_annotates_registered_files() {
|
||||
let (root, db) = scratch_db(&["textures/wall.png", "textures/env/sky.png", "notes.md"]);
|
||||
|
||||
let top = list_dir(&db, "");
|
||||
let names: Vec<&str> = top.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["textures", "notes.md"]);
|
||||
assert!(top[0].is_dir && top[0].uid.is_none());
|
||||
assert_eq!(top[1].kind, Some(AssetKind::Other));
|
||||
|
||||
let textures = list_dir(&db, "textures");
|
||||
let names: Vec<&str> = textures.iter().map(|e| e.name.as_str()).collect();
|
||||
assert_eq!(names, ["env", "wall.png"]);
|
||||
assert_eq!(textures[1].kind, Some(AssetKind::Texture));
|
||||
assert_eq!(textures[1].uid, db.uid_of("textures/wall.png"));
|
||||
|
||||
// A folder that does not exist lists as empty, not an error.
|
||||
assert!(list_dir(&db, "nope").is_empty());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_name_suffixes_before_the_extension() {
|
||||
let (root, db) = scratch_db(&["textures/wall.png"]);
|
||||
let dir = db.assets_dir().join("textures");
|
||||
assert_eq!(unique_name(&dir, "new.png"), "new.png");
|
||||
assert_eq!(unique_name(&dir, "wall.png"), "wall_2.png");
|
||||
std::fs::write(dir.join("wall_2.png"), b"x").unwrap();
|
||||
assert_eq!(unique_name(&dir, "wall.png"), "wall_3.png");
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_folder_is_unique_and_validated() {
|
||||
let (root, db) = scratch_db(&[]);
|
||||
assert_eq!(create_folder(&db, "", "props").unwrap(), "props");
|
||||
assert_eq!(create_folder(&db, "", "props").unwrap(), "props_2");
|
||||
assert_eq!(create_folder(&db, "props", "env").unwrap(), "props/env");
|
||||
assert!(create_folder(&db, "", "a/b").is_err());
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_and_move_preserve_uids() {
|
||||
let (root, mut db) = scratch_db(&["textures/wall.png", "textures/env/sky.png"]);
|
||||
let wall = db.uid_of("textures/wall.png").unwrap();
|
||||
let sky = db.uid_of("textures/env/sky.png").unwrap();
|
||||
|
||||
// Rename a file in place.
|
||||
let new_rel = rename_entry(&mut db, "textures/wall.png", false, "brick.png").unwrap();
|
||||
assert_eq!(new_rel, "textures/brick.png");
|
||||
assert_eq!(db.relative_path(wall), Some("textures/brick.png"));
|
||||
|
||||
// Move it into a sibling folder.
|
||||
let new_rel = move_entry(&mut db, "textures/brick.png", false, "textures/env").unwrap();
|
||||
assert_eq!(new_rel, "textures/env/brick.png");
|
||||
assert_eq!(db.relative_path(wall), Some("textures/env/brick.png"));
|
||||
|
||||
// Rename the folder: both entries follow, uids intact.
|
||||
let new_rel = rename_entry(&mut db, "textures/env", true, "world").unwrap();
|
||||
assert_eq!(new_rel, "textures/world");
|
||||
assert_eq!(db.relative_path(sky), Some("textures/world/sky.png"));
|
||||
assert_eq!(db.relative_path(wall), Some("textures/world/brick.png"));
|
||||
|
||||
// Invalid target name is refused.
|
||||
assert!(rename_entry(&mut db, "textures/world", true, "a/b").is_err());
|
||||
|
||||
// The manifest was persisted along the way.
|
||||
let reloaded = AssetDatabase::open(&root);
|
||||
assert_eq!(reloaded.relative_path(sky), Some("textures/world/sky.png"));
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregistered_files_rename_through_the_filesystem() {
|
||||
let (root, mut db) = scratch_db(&[]);
|
||||
// A file the database does not track (e.g. a license dropped next to
|
||||
// a font). Note scratch_db scans, so create it *after*.
|
||||
let assets = db.assets_dir();
|
||||
std::fs::write(assets.join("OFL.txt"), b"x").unwrap();
|
||||
assert!(db.uid_of("OFL.txt").is_none());
|
||||
|
||||
let new_rel = rename_entry(&mut db, "OFL.txt", false, "LICENSE.txt").unwrap();
|
||||
assert_eq!(new_rel, "LICENSE.txt");
|
||||
assert!(assets.join("LICENSE.txt").is_file());
|
||||
assert!(!assets.join("OFL.txt").exists());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_rules_files_yes_folders_only_when_empty() {
|
||||
let (root, mut db) = scratch_db(&["textures/wall.png"]);
|
||||
let wall_entry = list_dir(&db, "textures")
|
||||
.into_iter()
|
||||
.find(|e| e.name == "wall.png")
|
||||
.unwrap();
|
||||
let folder_entry = list_dir(&db, "")
|
||||
.into_iter()
|
||||
.find(|e| e.name == "textures")
|
||||
.unwrap();
|
||||
|
||||
// Non-empty folder refused; file deletes (entry + disk); empty folder ok.
|
||||
assert!(delete_entry(&mut db, &folder_entry).is_err());
|
||||
delete_entry(&mut db, &wall_entry).unwrap();
|
||||
assert!(db.uid_of("textures/wall.png").is_none());
|
||||
delete_entry(&mut db, &folder_entry).unwrap();
|
||||
assert!(list_dir(&db, "").is_empty());
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_copies_registers_and_dodges_collisions() {
|
||||
let (root, mut db) = scratch_db(&["textures/wall.png"]);
|
||||
// Two outside files, one colliding with an existing asset name.
|
||||
let outside = root.join("outside");
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
std::fs::write(outside.join("wall.png"), b"new").unwrap();
|
||||
std::fs::write(outside.join("tree.glb"), b"tree").unwrap();
|
||||
|
||||
let n = import_files(
|
||||
&mut db,
|
||||
"textures",
|
||||
&[outside.join("wall.png"), outside.join("tree.glb")],
|
||||
);
|
||||
assert_eq!(n, 2);
|
||||
assert!(db.uid_of("textures/wall_2.png").is_some());
|
||||
// Kind follows the *folder* it was dropped into.
|
||||
let tree = db.uid_of("textures/tree.glb").unwrap();
|
||||
assert_eq!(db.entry(tree).unwrap().kind, AssetKind::Texture);
|
||||
|
||||
std::fs::remove_dir_all(root).ok();
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ pub mod bindings;
|
||||
pub mod command;
|
||||
pub mod commands;
|
||||
pub mod console;
|
||||
pub mod explorer;
|
||||
pub mod extension;
|
||||
pub mod gizmo;
|
||||
pub mod play;
|
||||
|
||||
+41
-662
@@ -39,7 +39,7 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use egui_dock::{DockArea, DockState, NodeIndex, Style};
|
||||
use oxide_engine::asset::{asset_ref_target, AssetDatabase};
|
||||
use oxide_engine::asset::{asset_ref_target, AssetDatabase, AssetEntry};
|
||||
use oxide_engine::input::{Binding, InputState};
|
||||
use oxide_engine::prelude::*;
|
||||
use oxide_engine::project::{Project, ProjectError};
|
||||
@@ -52,7 +52,7 @@ use crate::command::{Command, CommandStack};
|
||||
use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd};
|
||||
use crate::extension::EditorExtensions;
|
||||
use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis};
|
||||
use crate::state::{EditorState, ExternalEditorPrefs, PlayState, EXTERNAL_EDITOR_SECTION};
|
||||
use crate::state::{EditorState, PlayState};
|
||||
use oxide_engine::math::{Mat4, Vec4};
|
||||
use oxide_engine::reflect::FieldInfo;
|
||||
|
||||
@@ -104,26 +104,6 @@ impl PanelKind {
|
||||
/// despawn / reparent). Tracked here only because the hierarchy panel builds
|
||||
/// these while the UI closure runs and applies them after, the same idiom
|
||||
/// the Stage-5 main.rs used.
|
||||
/// Drag payload for a Project-panel explorer row: what is being moved.
|
||||
/// Dropping it on a folder row (or the ".." row) moves the file/folder there
|
||||
/// through the database's uid-preserving ops.
|
||||
#[derive(Clone)]
|
||||
struct ExplorerDragPayload {
|
||||
/// Assets-relative path of the dragged entry.
|
||||
rel: String,
|
||||
/// Whether it is a folder.
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
/// Which path field a finished native folder pick fills in.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FolderPickTarget {
|
||||
/// The "New Project" dialog's project-folder field.
|
||||
NewProject,
|
||||
/// The "Open Project" dialog's path field.
|
||||
OpenProject,
|
||||
}
|
||||
|
||||
enum PendingAction {
|
||||
/// Spawn a named prefab as a root entity (data-driven add-menu). The
|
||||
/// `Empty` prefab is a bare node; the rest carry components.
|
||||
@@ -543,15 +523,8 @@ pub struct Shell {
|
||||
new_project_name: String,
|
||||
/// Text entered in the "Open Project" modal.
|
||||
open_project_path: String,
|
||||
/// An in-flight native folder pick (Browse… in New/Open Project): which
|
||||
/// field the result lands in + the channel the dialog thread reports on.
|
||||
/// The dialog runs on its own thread so the UI keeps redrawing; `None`
|
||||
/// means no pick is up (and gates the Browse buttons to one at a time).
|
||||
folder_pick: Option<(FolderPickTarget, std::sync::mpsc::Receiver<Option<PathBuf>>)>,
|
||||
/// Text entered in the "Groups" editor's "add group" field.
|
||||
new_group_name: String,
|
||||
/// Text entered in the Script inspector's "New Script" name field.
|
||||
new_script_name: String,
|
||||
/// The Console panel's command-input buffer (the terminal prompt).
|
||||
terminal_input: String,
|
||||
|
||||
@@ -566,10 +539,6 @@ pub struct Shell {
|
||||
/// pick) versus over an opaque panel.
|
||||
viewport_rect_px: Option<(f32, f32, f32, f32)>,
|
||||
|
||||
/// The Project panel's file-explorer state (current folder, in-progress
|
||||
/// rename / new-folder edits). Behavior lives in [`crate::explorer`].
|
||||
explorer: crate::explorer::ExplorerState,
|
||||
|
||||
// --- per-frame UI scratch -----------------------------------------
|
||||
pending: Vec<PendingAction>,
|
||||
rename_buf: String,
|
||||
@@ -654,10 +623,7 @@ impl Shell {
|
||||
new_project_path: String::new(),
|
||||
new_project_name: String::new(),
|
||||
open_project_path: String::new(),
|
||||
folder_pick: None,
|
||||
explorer: crate::explorer::ExplorerState::default(),
|
||||
new_group_name: String::new(),
|
||||
new_script_name: String::new(),
|
||||
terminal_input: String::new(),
|
||||
quit_requested: false,
|
||||
viewport_rect_px: None,
|
||||
@@ -1134,9 +1100,6 @@ impl Shell {
|
||||
/// closure more than once during layout.
|
||||
pub fn build(&mut self, ui: &mut egui::Ui) {
|
||||
self.pending.clear();
|
||||
// Collect a native folder pick before the project dialogs render, so
|
||||
// a chosen path appears in its field the same frame.
|
||||
self.poll_folder_pick();
|
||||
|
||||
self.menu_bar(ui);
|
||||
self.play_toolbar(ui);
|
||||
@@ -1162,8 +1125,6 @@ impl Shell {
|
||||
extensions: &mut self.extensions,
|
||||
pending: &mut self.pending,
|
||||
rename_buf: &mut self.rename_buf,
|
||||
explorer: &mut self.explorer,
|
||||
new_script_name: &mut self.new_script_name,
|
||||
terminal_input: &mut self.terminal_input,
|
||||
rot_euler: &mut self.rot_euler,
|
||||
euler_for: &mut self.euler_for,
|
||||
@@ -1498,8 +1459,6 @@ impl Shell {
|
||||
// section editor lands.
|
||||
if name == crate::bindings::SETTINGS_SECTION {
|
||||
ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui));
|
||||
} else if name == EXTERNAL_EDITOR_SECTION {
|
||||
ui.collapsing("External Editor", |ui| self.external_editor_page(ui));
|
||||
} else {
|
||||
let ron = self.state.settings.section_ron(name);
|
||||
ui.collapsing(name, |ui| match ron {
|
||||
@@ -1523,37 +1482,6 @@ impl Shell {
|
||||
self.show_preferences = open;
|
||||
}
|
||||
|
||||
/// The External Editor preferences page: the command used when opening a
|
||||
/// script from the editor. Edits mark the preferences dirty so the host
|
||||
/// persists them like a binding remap.
|
||||
fn external_editor_page(&mut self, ui: &mut egui::Ui) {
|
||||
let Some(prefs) = self
|
||||
.state
|
||||
.settings
|
||||
.get_mut::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||
else {
|
||||
ui.weak("(section unavailable)");
|
||||
return;
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Open scripts with");
|
||||
if ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut prefs.command)
|
||||
.hint_text("auto — $VISUAL/$EDITOR, else xdg-open"),
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
self.bindings_dirty = true;
|
||||
}
|
||||
});
|
||||
ui.weak(
|
||||
"Launched as `<command> <file>` (the command may carry flags, e.g. `code -g`). \
|
||||
Leave empty for auto: $VISUAL/$EDITOR runs in a built-in Terminal tab, \
|
||||
otherwise the file opens via xdg-open.",
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the input-bindings preferences page: every registered editor
|
||||
/// action with its current bindings, a Change/Add/Clear control per
|
||||
/// slot, a per-action "Restore defaults" button, and a global
|
||||
@@ -1925,80 +1853,22 @@ impl Shell {
|
||||
self.show_group_editor = open;
|
||||
}
|
||||
|
||||
/// Launches the native folder picker on a helper thread, reporting into
|
||||
/// [`Shell::folder_pick`]. One pick at a time; the Browse buttons are
|
||||
/// disabled while one is up. `rfd`'s portal backend serves both Wayland
|
||||
/// and X11; if no portal service is running the thread reports `None`
|
||||
/// (same as cancel) and the typed path field still works.
|
||||
fn launch_folder_pick(&mut self, target: FolderPickTarget, start_dir: Option<PathBuf>) {
|
||||
if self.folder_pick.is_some() {
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut dialog = rfd::FileDialog::new().set_title("Choose a project folder");
|
||||
if let Some(dir) = start_dir.filter(|d| d.is_dir()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
// A dropped receiver (shell already gone) is fine to ignore.
|
||||
let _ = tx.send(dialog.pick_folder());
|
||||
});
|
||||
self.folder_pick = Some((target, rx));
|
||||
}
|
||||
|
||||
/// Collects a finished native folder pick into its target path field.
|
||||
/// Called once per frame from [`Shell::build`]; does nothing while the
|
||||
/// dialog is still up (the thread hasn't reported).
|
||||
fn poll_folder_pick(&mut self) {
|
||||
use std::sync::mpsc::TryRecvError;
|
||||
let Some((target, rx)) = self.folder_pick.take() else {
|
||||
return;
|
||||
};
|
||||
match rx.try_recv() {
|
||||
// Picked: fill the field the dialog was opened for.
|
||||
Ok(Some(path)) => {
|
||||
let text = path.display().to_string();
|
||||
match target {
|
||||
FolderPickTarget::NewProject => self.new_project_path = text,
|
||||
FolderPickTarget::OpenProject => self.open_project_path = text,
|
||||
}
|
||||
}
|
||||
// Cancelled (or the portal is unavailable): keep the typed text.
|
||||
Ok(None) | Err(TryRecvError::Disconnected) => {}
|
||||
// Still up: put it back and check again next frame.
|
||||
Err(TryRecvError::Empty) => self.folder_pick = Some((target, rx)),
|
||||
}
|
||||
}
|
||||
|
||||
/// In-app New Project dialog: a folder field with a native Browse… picker,
|
||||
/// a name field, and Create / Cancel. The path can still be typed by hand
|
||||
/// (the picker needs a running xdg-desktop-portal to appear).
|
||||
/// In-app New Project dialog: a path field, a name field, and Create / Cancel.
|
||||
/// A native OS file dialog (`rfd` or similar) is a piece-6 polish item — for
|
||||
/// now the path is typed, which is enough to exercise the flow end-to-end and
|
||||
/// keeps the editor dependency-light.
|
||||
fn new_project_window(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut open = self.show_new_project;
|
||||
let mut create_now = false;
|
||||
let mut cancel_now = false;
|
||||
let mut browse_now = false;
|
||||
let picking = self.folder_pick.is_some();
|
||||
egui::Window::new("New Project")
|
||||
.open(&mut open)
|
||||
.default_size([520.0, 160.0])
|
||||
.resizable(true)
|
||||
.show(&ctx, |ui| {
|
||||
ui.label("Project folder");
|
||||
ui.horizontal(|ui| {
|
||||
ui.text_edit_singleline(&mut self.new_project_path);
|
||||
if ui
|
||||
.add_enabled(!picking, egui::Button::new("Browse…"))
|
||||
.on_hover_text(
|
||||
"Pick the project folder with the system dialog \
|
||||
(it can create a new folder too)",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
browse_now = true;
|
||||
}
|
||||
});
|
||||
ui.label("Display name");
|
||||
ui.text_edit_singleline(&mut self.new_project_name);
|
||||
ui.add_space(6.0);
|
||||
@@ -2017,12 +1887,6 @@ impl Shell {
|
||||
project.oxide.",
|
||||
);
|
||||
});
|
||||
if browse_now {
|
||||
let start = Some(PathBuf::from(self.new_project_path.trim()))
|
||||
.filter(|p| p.is_dir())
|
||||
.or_else(|| std::env::var_os("HOME").map(PathBuf::from));
|
||||
self.launch_folder_pick(FolderPickTarget::NewProject, start);
|
||||
}
|
||||
if create_now {
|
||||
let path = PathBuf::from(self.new_project_path.trim());
|
||||
let name = self.new_project_name.trim().to_owned();
|
||||
@@ -2042,31 +1906,19 @@ impl Shell {
|
||||
}
|
||||
}
|
||||
|
||||
/// In-app Open Project dialog: a path field with a native Browse… picker,
|
||||
/// Open / Cancel.
|
||||
/// In-app Open Project dialog: one path field, Open / Cancel.
|
||||
fn open_project_window(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut open = self.show_open_project;
|
||||
let mut open_now = false;
|
||||
let mut cancel_now = false;
|
||||
let mut browse_now = false;
|
||||
let picking = self.folder_pick.is_some();
|
||||
egui::Window::new("Open Project")
|
||||
.open(&mut open)
|
||||
.default_size([520.0, 140.0])
|
||||
.resizable(true)
|
||||
.show(&ctx, |ui| {
|
||||
ui.label("Project folder (or path to project.oxide)");
|
||||
ui.horizontal(|ui| {
|
||||
ui.text_edit_singleline(&mut self.open_project_path);
|
||||
if ui
|
||||
.add_enabled(!picking, egui::Button::new("Browse…"))
|
||||
.on_hover_text("Pick the project folder with the system dialog")
|
||||
.clicked()
|
||||
{
|
||||
browse_now = true;
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let valid = !self.open_project_path.trim().is_empty();
|
||||
@@ -2078,16 +1930,6 @@ impl Shell {
|
||||
}
|
||||
});
|
||||
});
|
||||
if browse_now {
|
||||
let start = Some(PathBuf::from(self.open_project_path.trim()))
|
||||
.filter(|p| p.is_dir())
|
||||
.or_else(|| {
|
||||
let first = self.state.recent.entries().first()?;
|
||||
Some(first.parent()?.to_path_buf())
|
||||
})
|
||||
.or_else(|| std::env::var_os("HOME").map(PathBuf::from));
|
||||
self.launch_folder_pick(FolderPickTarget::OpenProject, start);
|
||||
}
|
||||
if open_now {
|
||||
let path = PathBuf::from(self.open_project_path.trim());
|
||||
match self.open_project(path) {
|
||||
@@ -2312,12 +2154,6 @@ struct ShellTabViewer<'a> {
|
||||
extensions: &'a mut EditorExtensions,
|
||||
pending: &'a mut Vec<PendingAction>,
|
||||
rename_buf: &'a mut String,
|
||||
/// The Project panel's file-explorer state. Borrowed from
|
||||
/// [`Shell::explorer`].
|
||||
explorer: &'a mut crate::explorer::ExplorerState,
|
||||
/// The Script inspector's "New Script" name field. Borrowed from
|
||||
/// [`Shell::new_script_name`].
|
||||
new_script_name: &'a mut String,
|
||||
/// The Console command-input buffer (the terminal prompt).
|
||||
terminal_input: &'a mut String,
|
||||
rot_euler: &'a mut Vec3,
|
||||
@@ -3528,21 +3364,6 @@ impl<'a> ShellTabViewer<'a> {
|
||||
edits.push((comp.name, row.info.name, new_ron));
|
||||
}
|
||||
}
|
||||
// Stage-10 UX: author and open scripts without leaving the
|
||||
// editor. The assignment goes through `edits` → SetFieldCmd
|
||||
// like any field change, so it is undoable (the created file
|
||||
// itself stays — harmless).
|
||||
if comp.name == "Script" {
|
||||
let source = comp
|
||||
.fields
|
||||
.iter()
|
||||
.find(|r| r.info.name == "source")
|
||||
.and_then(|r| ron::from_str::<Option<AssetUid>>(&r.value).ok())
|
||||
.flatten();
|
||||
if let Some(ron) = self.new_script_row(ui, source) {
|
||||
edits.push((comp.name, "source", ron));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3588,126 +3409,6 @@ impl<'a> ShellTabViewer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The script-tools row at the bottom of a `Script` component's section:
|
||||
/// a name field + "New Script" button that writes a `.rhai` template into
|
||||
/// `assets/scripts/`, registers it in the asset database, and returns the
|
||||
/// RON for the component's `source` field — the caller routes it through
|
||||
/// the normal edit path so the assignment is undoable (undo detaches the
|
||||
/// script; the created file stays, which is harmless) — plus an "Edit"
|
||||
/// button opening the currently assigned script (`source`) in the user's
|
||||
/// editor. Disabled until a project is open. Errors go to the log, i.e.
|
||||
/// the Console panel.
|
||||
fn new_script_row(&mut self, ui: &mut egui::Ui, source: Option<AssetUid>) -> Option<String> {
|
||||
let mut created = None;
|
||||
ui.horizontal(|ui| {
|
||||
let open = self.state.asset_db.is_some();
|
||||
ui.add_enabled_ui(open, |ui| {
|
||||
let name = ui.add(
|
||||
egui::TextEdit::singleline(self.new_script_name)
|
||||
.hint_text("new_script")
|
||||
.desired_width(120.0),
|
||||
);
|
||||
let submitted = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let clicked = ui
|
||||
.small_button("➕ New Script")
|
||||
.on_hover_text(
|
||||
"Create a .rhai file from the template in assets/scripts/ \
|
||||
and assign it to this component",
|
||||
)
|
||||
.clicked();
|
||||
if clicked || submitted {
|
||||
let db = self
|
||||
.state
|
||||
.asset_db
|
||||
.as_mut()
|
||||
.expect("row is enabled only with a project open");
|
||||
match crate::assets::create_script_file(&db.assets_dir(), self.new_script_name)
|
||||
{
|
||||
Ok(rel) => {
|
||||
let uid = db.register(&rel);
|
||||
if let Err(err) = db.save() {
|
||||
log::warn!("could not write asset manifest: {err}");
|
||||
}
|
||||
log::info!("created {rel}");
|
||||
self.new_script_name.clear();
|
||||
created = ron::to_string(&Some(uid)).ok();
|
||||
}
|
||||
Err(err) => log::warn!("could not create script: {err}"),
|
||||
}
|
||||
}
|
||||
let edit = ui
|
||||
.add_enabled(source.is_some(), egui::Button::new("✏ Edit").small())
|
||||
.on_hover_text(
|
||||
"Open the assigned script in your editor (External Editor \
|
||||
preference, else $VISUAL/$EDITOR in a Terminal tab, else \
|
||||
xdg-open). Saved edits live-reload.",
|
||||
);
|
||||
if edit.clicked() {
|
||||
if let Some(uid) = source {
|
||||
self.open_script_in_editor(uid);
|
||||
}
|
||||
}
|
||||
});
|
||||
if !open {
|
||||
ui.label(egui::RichText::new("(open a project to create scripts)").weak());
|
||||
}
|
||||
});
|
||||
created
|
||||
}
|
||||
|
||||
/// Opens the script asset `uid` in the user's editor. Resolution order:
|
||||
///
|
||||
/// 1. the **External Editor** preference command, spawned detached as
|
||||
/// `<command> <file>`;
|
||||
/// 2. `$VISUAL` / `$EDITOR`, run in a new **Terminal-panel tab** (so TUI
|
||||
/// editors like vim/nano work in-editor);
|
||||
/// 3. `xdg-open` (the desktop's default handler).
|
||||
///
|
||||
/// Whichever way, saved edits flow back through the file watcher's live
|
||||
/// reload — including into a playing scene.
|
||||
fn open_script_in_editor(&mut self, uid: AssetUid) {
|
||||
let Some(abs) = self
|
||||
.state
|
||||
.asset_db
|
||||
.as_ref()
|
||||
.and_then(|db| db.absolute_path(uid))
|
||||
else {
|
||||
log::warn!("script asset {uid:?} has no file to open");
|
||||
return;
|
||||
};
|
||||
let configured = self
|
||||
.state
|
||||
.settings
|
||||
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||
.map(|p| p.command.trim().to_owned())
|
||||
.unwrap_or_default();
|
||||
if !configured.is_empty() {
|
||||
spawn_detached(&configured, &abs);
|
||||
return;
|
||||
}
|
||||
let not_blank = |v: String| (!v.trim().is_empty()).then_some(v);
|
||||
let terminal_editor = std::env::var("VISUAL")
|
||||
.ok()
|
||||
.and_then(not_blank)
|
||||
.or_else(|| std::env::var("EDITOR").ok().and_then(not_blank));
|
||||
match terminal_editor {
|
||||
Some(editor) => {
|
||||
let leaf = abs
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "script".to_owned());
|
||||
// $EDITOR may carry flags — whitespace-split it like the
|
||||
// configured command, then append the file path.
|
||||
let mut parts = editor.split_whitespace().map(str::to_owned);
|
||||
let program = parts.next().unwrap_or_else(|| "vi".to_owned());
|
||||
let owned: Vec<String> = parts.chain([abs.display().to_string()]).collect();
|
||||
let args: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||
self.launch_terminal(&format!("edit {leaf}"), &program, &args);
|
||||
}
|
||||
None => spawn_detached("xdg-open", &abs),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one reflected field as a typed widget chosen from its
|
||||
/// `type_name`, returning the field's new RON if the user changed it.
|
||||
/// Unknown types fall back to an editable RON text box, so the inspector is
|
||||
@@ -4004,11 +3705,10 @@ impl<'a> ShellTabViewer<'a> {
|
||||
ui.monospace(project.root().display().to_string());
|
||||
ui.separator();
|
||||
|
||||
// File explorer over assets/ (Stage-10 editor-UX): breadcrumbs,
|
||||
// folder navigation, rename/delete/move, and OS drag-in import — all
|
||||
// over the AssetDatabase so every operation keeps uids (and saved
|
||||
// AssetRefs) intact. Importing also still works by dropping a file
|
||||
// into assets/ on disk; the watcher or Rescan registers it.
|
||||
// Asset browser: typed folders grouped by kind, listed from the
|
||||
// database (the source of truth for what the picker can reference).
|
||||
// "Importing" an asset is just dropping the file into the right folder;
|
||||
// the file watcher rescans, or the user can rescan on demand.
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Assets");
|
||||
if ui
|
||||
@@ -4021,16 +3721,13 @@ impl<'a> ShellTabViewer<'a> {
|
||||
let _ = db.save();
|
||||
}
|
||||
}
|
||||
if ui
|
||||
.small_button("➕ New Folder")
|
||||
.on_hover_text("Create a folder in the current directory")
|
||||
.clicked()
|
||||
{
|
||||
self.explorer.new_folder = Some(String::new());
|
||||
self.explorer.focus_field = true;
|
||||
}
|
||||
});
|
||||
self.assets_explorer(ui);
|
||||
match &self.state.asset_db {
|
||||
Some(db) => asset_browser(ui, db),
|
||||
None => {
|
||||
ui.weak("(asset database unavailable)");
|
||||
}
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
let project = self.state.project.as_ref().unwrap();
|
||||
@@ -4038,325 +3735,6 @@ impl<'a> ShellTabViewer<'a> {
|
||||
show_project_tree(ui, "scripts/", project.scripts_dir());
|
||||
}
|
||||
|
||||
/// The Unity-style file explorer over `assets/`: breadcrumbs, folder
|
||||
/// navigation (double-click), inline rename / new-folder rows, per-row
|
||||
/// context menus, drag-to-move between folders, and OS drag-in import
|
||||
/// into the current folder. All behavior lives in [`crate::explorer`];
|
||||
/// this only renders it and collects intents (applied after the render
|
||||
/// pass, so no closure ever borrows `self.state` and the explorer
|
||||
/// buffers at once). File operations act immediately and bypass the undo
|
||||
/// stack — like the hierarchy's structural edits — but every move goes
|
||||
/// through the database's uid-preserving ops, so saved `AssetRef`s keep
|
||||
/// resolving.
|
||||
fn assets_explorer(&mut self, ui: &mut egui::Ui) {
|
||||
use crate::explorer::{self as exp, Entry};
|
||||
|
||||
let Some(db) = &self.state.asset_db else {
|
||||
ui.weak("(asset database unavailable)");
|
||||
return;
|
||||
};
|
||||
let cwd = self.explorer.cwd.clone();
|
||||
let entries = exp::list_dir(db, &cwd);
|
||||
let crumbs = exp::breadcrumbs(&cwd);
|
||||
|
||||
// Intents collected during render, applied afterwards.
|
||||
let mut nav: Option<String> = None;
|
||||
let mut open: Option<Entry> = None;
|
||||
let mut start_rename: Option<Entry> = None;
|
||||
let mut delete: Option<Entry> = None;
|
||||
// (what to move: rel + is_dir, destination folder)
|
||||
let mut moved: Option<(String, bool, String)> = None;
|
||||
let mut commit_rename = false;
|
||||
let mut cancel_rename = false;
|
||||
let mut commit_new_folder = false;
|
||||
let mut cancel_new_folder = false;
|
||||
|
||||
// Breadcrumbs: assets / textures / env — click a segment to jump.
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
for (i, (label, target)) in crumbs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
ui.weak("/");
|
||||
}
|
||||
let here = *target == cwd;
|
||||
if ui.selectable_label(here, label).clicked() && !here {
|
||||
nav = Some(target.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Drag state shared by every drop target this frame.
|
||||
let dragged = egui::DragAndDrop::payload::<ExplorerDragPayload>(ui.ctx());
|
||||
let pointer = ui.ctx().pointer_interact_pos();
|
||||
let released = ui.input(|i| i.pointer.any_released());
|
||||
let accent = ui.visuals().selection.bg_fill;
|
||||
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let focus_now = std::mem::take(&mut self.explorer.focus_field);
|
||||
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("oxide.explorer")
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
// Inline "New Folder" row.
|
||||
if let Some(buf) = &mut self.explorer.new_folder {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("▸");
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(buf)
|
||||
.hint_text("folder name")
|
||||
.desired_width(160.0),
|
||||
);
|
||||
if focus_now {
|
||||
resp.request_focus();
|
||||
}
|
||||
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
commit_new_folder = true;
|
||||
}
|
||||
if ui.small_button("Create").clicked() {
|
||||
commit_new_folder = true;
|
||||
}
|
||||
if escape {
|
||||
cancel_new_folder = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ".." row: double-click to go up; also a drop target that
|
||||
// moves items into the parent folder.
|
||||
if !cwd.is_empty() {
|
||||
let up = exp::parent_of(&cwd);
|
||||
let resp = ui
|
||||
.add(
|
||||
egui::Label::new("▸ ..")
|
||||
.selectable(false)
|
||||
.sense(egui::Sense::click()),
|
||||
)
|
||||
.on_hover_text("Double-click to go up — drop items here to move them up");
|
||||
if resp.double_clicked() {
|
||||
nav = Some(up.clone());
|
||||
}
|
||||
if let (Some(payload), Some(pos)) = (&dragged, pointer) {
|
||||
if resp.rect.contains(pos) {
|
||||
stroke_rect(
|
||||
ui.painter(),
|
||||
resp.rect,
|
||||
egui::Stroke::new(1.5_f32, accent),
|
||||
);
|
||||
if released {
|
||||
moved = Some((payload.rel.clone(), payload.is_dir, up.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entries.is_empty() && self.explorer.new_folder.is_none() {
|
||||
ui.weak("(empty — drag files in from your file manager to import)");
|
||||
}
|
||||
|
||||
for entry in &entries {
|
||||
// An entry mid-rename renders as a text field instead.
|
||||
let renaming = self
|
||||
.explorer
|
||||
.rename
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.rel == entry.rel);
|
||||
if renaming {
|
||||
let buf = &mut self.explorer.rename.as_mut().expect("checked").buf;
|
||||
ui.horizontal(|ui| {
|
||||
let resp = ui.add(egui::TextEdit::singleline(buf).desired_width(180.0));
|
||||
if focus_now {
|
||||
resp.request_focus();
|
||||
}
|
||||
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
commit_rename = true;
|
||||
}
|
||||
if ui.small_button("Rename").clicked() {
|
||||
commit_rename = true;
|
||||
}
|
||||
if escape {
|
||||
cancel_rename = true;
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let display = if entry.is_dir {
|
||||
format!("▸ {}/", entry.name)
|
||||
} else {
|
||||
entry.name.clone()
|
||||
};
|
||||
let hover = match (entry.is_dir, entry.kind) {
|
||||
(true, _) => format!(
|
||||
"{}/\ndouble-click to open · drag to move · right-click for actions",
|
||||
entry.rel
|
||||
),
|
||||
(false, Some(kind)) => format!(
|
||||
"{}\n{kind:?} asset · double-click to open · drag to move",
|
||||
entry.rel
|
||||
),
|
||||
(false, None) => {
|
||||
format!("{}\nunregistered file · drag to move", entry.rel)
|
||||
}
|
||||
};
|
||||
// One widget senses click + drag (same pattern as the
|
||||
// hierarchy rows): still press = click, press+move = drag.
|
||||
let resp = ui
|
||||
.add(
|
||||
egui::Label::new(display)
|
||||
.selectable(false)
|
||||
.sense(egui::Sense::click_and_drag()),
|
||||
)
|
||||
.on_hover_text(hover);
|
||||
if resp.drag_started() {
|
||||
egui::DragAndDrop::set_payload(
|
||||
ui.ctx(),
|
||||
ExplorerDragPayload {
|
||||
rel: entry.rel.clone(),
|
||||
is_dir: entry.is_dir,
|
||||
},
|
||||
);
|
||||
}
|
||||
if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
if entry.is_dir {
|
||||
nav = Some(entry.rel.clone());
|
||||
} else {
|
||||
open = Some(entry.clone());
|
||||
}
|
||||
}
|
||||
// Folder rows accept drops (not from themselves).
|
||||
if entry.is_dir {
|
||||
if let (Some(payload), Some(pos)) = (&dragged, pointer) {
|
||||
if payload.rel != entry.rel && resp.rect.contains(pos) {
|
||||
stroke_rect(
|
||||
ui.painter(),
|
||||
resp.rect,
|
||||
egui::Stroke::new(1.5_f32, accent),
|
||||
);
|
||||
if released {
|
||||
moved = Some((
|
||||
payload.rel.clone(),
|
||||
payload.is_dir,
|
||||
entry.rel.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.context_menu(|ui| {
|
||||
if ui.button("Open").clicked() {
|
||||
if entry.is_dir {
|
||||
nav = Some(entry.rel.clone());
|
||||
} else {
|
||||
open = Some(entry.clone());
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("✏ Rename").clicked() {
|
||||
start_rename = Some(entry.clone());
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("🗑 Delete").clicked() {
|
||||
delete = Some(entry.clone());
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// OS drag-in import: while files hover the window, hint; when they
|
||||
// drop (and the Project panel is showing), copy + register them into
|
||||
// the current folder.
|
||||
if ui.input(|i| !i.raw.hovered_files.is_empty()) {
|
||||
ui.colored_label(accent, format!("Drop to import into assets/{cwd}"));
|
||||
}
|
||||
let dropped: Vec<PathBuf> = ui.input(|i| {
|
||||
i.raw
|
||||
.dropped_files
|
||||
.iter()
|
||||
.filter_map(|f| f.path.clone())
|
||||
.collect()
|
||||
});
|
||||
|
||||
// ---- apply the collected intents ---------------------------------
|
||||
if cancel_new_folder {
|
||||
self.explorer.new_folder = None;
|
||||
} else if commit_new_folder {
|
||||
let wanted = self.explorer.new_folder.take().unwrap_or_default();
|
||||
if let Some(db) = &self.state.asset_db {
|
||||
match exp::create_folder(db, &cwd, wanted.trim()) {
|
||||
Ok(rel) => log::info!("created folder assets/{rel}"),
|
||||
Err(err) => log::warn!("could not create folder: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel_rename {
|
||||
self.explorer.rename = None;
|
||||
} else if commit_rename {
|
||||
if let Some(edit) = self.explorer.rename.take() {
|
||||
if let Some(db) = self.state.asset_db.as_mut() {
|
||||
match exp::rename_entry(db, &edit.rel, edit.is_dir, edit.buf.trim()) {
|
||||
Ok(new_rel) => log::info!("renamed {} -> {new_rel}", edit.rel),
|
||||
Err(err) => log::warn!("could not rename {}: {err}", edit.rel),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(entry) = start_rename {
|
||||
self.explorer.rename = Some(crate::explorer::RenameEdit {
|
||||
rel: entry.rel,
|
||||
is_dir: entry.is_dir,
|
||||
buf: entry.name,
|
||||
});
|
||||
self.explorer.focus_field = true;
|
||||
}
|
||||
if let Some((rel, is_dir, dest)) = moved {
|
||||
if let Some(db) = self.state.asset_db.as_mut() {
|
||||
match exp::move_entry(db, &rel, is_dir, &dest) {
|
||||
Ok(new_rel) => log::info!("moved {rel} -> {new_rel}"),
|
||||
Err(err) => log::warn!("could not move {rel}: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(entry) = delete {
|
||||
if let Some(db) = self.state.asset_db.as_mut() {
|
||||
match exp::delete_entry(db, &entry) {
|
||||
Ok(()) => log::info!("deleted {}", entry.rel),
|
||||
Err(err) => log::warn!("could not delete {}: {err}", entry.rel),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(entry) = open {
|
||||
if entry.kind == Some(AssetKind::Script) {
|
||||
if let Some(uid) = entry.uid {
|
||||
self.open_script_in_editor(uid);
|
||||
}
|
||||
} else {
|
||||
let abs = self.state.asset_db.as_ref().map(|db| {
|
||||
db.assets_dir()
|
||||
.join(entry.rel.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||
});
|
||||
if let Some(abs) = abs {
|
||||
spawn_detached("xdg-open", &abs);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !dropped.is_empty() {
|
||||
if let Some(db) = self.state.asset_db.as_mut() {
|
||||
let n = exp::import_files(db, &cwd, &dropped);
|
||||
if n > 0 {
|
||||
log::info!("imported {n} file(s) into assets/{cwd}");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(target) = nav {
|
||||
self.explorer.navigate(target);
|
||||
}
|
||||
}
|
||||
|
||||
/// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview,
|
||||
/// and a property panel (including the font-asset picker) — saved as a
|
||||
/// `ui/` asset. Structural and property edits route through
|
||||
@@ -4937,29 +4315,6 @@ impl<'a> ShellTabViewer<'a> {
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
|
||||
/// Spawns `command` (whitespace-split, so it may carry flags) with `file`
|
||||
/// appended as the final argument, detached from the editor; a reaper thread
|
||||
/// waits on the child so it never lingers as a zombie. Failures are logged
|
||||
/// (→ the Console), never fatal.
|
||||
fn spawn_detached(command: &str, file: &std::path::Path) {
|
||||
let mut parts = command.split_whitespace();
|
||||
let Some(program) = parts.next() else {
|
||||
return;
|
||||
};
|
||||
match std::process::Command::new(program)
|
||||
.args(parts)
|
||||
.arg(file)
|
||||
.spawn()
|
||||
{
|
||||
Ok(mut child) => {
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
});
|
||||
}
|
||||
Err(err) => log::warn!("could not launch {command}: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the rendered control changed any axis this frame.
|
||||
/// Projects a world-space point through `view_proj` to screen pixels
|
||||
/// inside `tab_rect` (egui logical points). Returns `None` when the point
|
||||
@@ -5364,6 +4719,10 @@ fn snapshot_node(
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the typed asset folders (one collapsing section per [`AssetKind`])
|
||||
/// from the database, listing each asset by its name. Drop a file into the
|
||||
/// matching folder on disk to import it; the watcher (or the Rescan button)
|
||||
/// registers it.
|
||||
/// The colour for a console log line of the given severity: errors red, warnings
|
||||
/// amber, info the normal text colour, debug/trace dimmed.
|
||||
fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 {
|
||||
@@ -5434,6 +4793,26 @@ fn build_terminal_job(
|
||||
job
|
||||
}
|
||||
|
||||
fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) {
|
||||
for kind in AssetKind::TYPED {
|
||||
let mut entries: Vec<&AssetEntry> = db.entries_of_kind(kind).collect();
|
||||
entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
let header = format!("{}/ ({})", kind.folder(), entries.len());
|
||||
egui::CollapsingHeader::new(header)
|
||||
.id_salt(("oxide.assets", kind.folder()))
|
||||
.show(ui, |ui| {
|
||||
if entries.is_empty() {
|
||||
ui.weak("(empty — drop files here)");
|
||||
}
|
||||
for entry in entries {
|
||||
// Show the leaf name; the full relative path on hover.
|
||||
let leaf = entry.path.rsplit('/').next().unwrap_or(&entry.path);
|
||||
ui.label(leaf).on_hover_text(&entry.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A short label for a widget in the tree / property header: its id if set,
|
||||
/// else its kind, with the root marked.
|
||||
fn widget_label(widget: &Widget, path: &WidgetPath) -> String {
|
||||
|
||||
@@ -209,20 +209,6 @@ impl Default for UiDoc {
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings section holding [`ExternalEditorPrefs`].
|
||||
pub const EXTERNAL_EDITOR_SECTION: &str = "editor.external_editor";
|
||||
|
||||
/// Preferences for opening a script (or other text asset) in an editor —
|
||||
/// registered as the [`EXTERNAL_EDITOR_SECTION`] settings section, editable in
|
||||
/// Preferences, persisted to `~/.config/oxide/editor.ron` like the bindings.
|
||||
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ExternalEditorPrefs {
|
||||
/// Command to launch, invoked as `<command> <file>` (whitespace-split; may
|
||||
/// carry its own flags, e.g. `"code -g"`). **Empty (the default) = auto**:
|
||||
/// run `$VISUAL`/`$EDITOR` in an editor Terminal tab, else `xdg-open`.
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// A blank state with an empty scene, no open project, and the editor's
|
||||
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
|
||||
@@ -238,7 +224,6 @@ impl EditorState {
|
||||
bindings::register_defaults(&mut actions);
|
||||
let mut settings = Settings::new();
|
||||
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
|
||||
settings.register::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION);
|
||||
let mut registry = TypeRegistry::new();
|
||||
register_builtin_types(&mut registry);
|
||||
// Seed a small, generally-useful set of named layers (besides the
|
||||
@@ -605,17 +590,4 @@ mod tests {
|
||||
.get::<oxide_physics::RigidBody>(restored)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_editor_section_is_registered_and_defaults_to_auto() {
|
||||
let state = EditorState::new();
|
||||
let prefs = state
|
||||
.settings
|
||||
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||
.expect("external-editor settings section must be registered");
|
||||
assert!(
|
||||
prefs.command.is_empty(),
|
||||
"default is empty = auto ($VISUAL/$EDITOR terminal tab, else xdg-open)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,21 +440,18 @@ impl AssetDatabase {
|
||||
uid
|
||||
}
|
||||
|
||||
/// Scans everything under `assets/` and reconciles the database with what
|
||||
/// is on disk: existing files keep their uid, new files are
|
||||
/// [registered](Self::register), and entries whose files no longer exist
|
||||
/// Scans the typed asset folders under `assets/` and reconciles the
|
||||
/// database with what is on disk: existing files keep their uid, new files
|
||||
/// are [registered](Self::register), and entries whose files no longer exist
|
||||
/// are dropped. Returns the number of newly registered assets.
|
||||
///
|
||||
/// The walk covers the **whole** assets tree, not just the typed folders —
|
||||
/// the editor's file explorer lets a project organise assets in arbitrary
|
||||
/// folders, and a scan must never prune them. Files outside a typed folder
|
||||
/// classify by extension (else [`Other`](AssetKind::Other)), as always.
|
||||
///
|
||||
/// Call [`save`](Self::save) afterwards to persist any new uids.
|
||||
pub fn scan(&mut self) -> usize {
|
||||
let assets_dir = self.assets_dir();
|
||||
let mut found: Vec<String> = Vec::new();
|
||||
collect_files(&assets_dir, &assets_dir, &mut found);
|
||||
for kind in AssetKind::TYPED {
|
||||
collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
|
||||
}
|
||||
|
||||
// Drop entries whose backing file disappeared.
|
||||
let present: std::collections::HashSet<&String> = found.iter().collect();
|
||||
@@ -814,27 +811,12 @@ mod tests {
|
||||
assert_eq!(db.len(), 3);
|
||||
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
|
||||
|
||||
// The walk covers the whole tree: a custom folder and a loose root
|
||||
// file both register (kind from extension, else Other) and survive
|
||||
// subsequent scans.
|
||||
touch_asset(&root, "props/crate.glb");
|
||||
touch_asset(&root, "notes.md");
|
||||
assert_eq!(db.scan(), 2);
|
||||
let crate_uid = db.uid_of("props/crate.glb").unwrap();
|
||||
assert_eq!(db.entry(crate_uid).unwrap().kind, AssetKind::Model);
|
||||
assert_eq!(
|
||||
db.entry(db.uid_of("notes.md").unwrap()).unwrap().kind,
|
||||
AssetKind::Other
|
||||
);
|
||||
assert_eq!(db.scan(), 0);
|
||||
assert_eq!(db.uid_of("props/crate.glb"), Some(crate_uid));
|
||||
|
||||
// Remove one file and rescan: it is pruned, the rest keep their uids.
|
||||
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
||||
let wall_uid = db.uid_of("textures/wall.png").unwrap();
|
||||
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
|
||||
assert_eq!(db.scan(), 0);
|
||||
assert_eq!(db.len(), 4, "wall + cube + crate + notes remain");
|
||||
assert_eq!(db.len(), 2);
|
||||
assert!(db.entry(font_uid).is_none());
|
||||
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
|
||||
|
||||
@@ -940,11 +922,12 @@ mod tests {
|
||||
db.move_asset(uid, "misc/brick.dat").unwrap();
|
||||
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
|
||||
|
||||
// A rescan (which walks the whole tree) does not disturb the moved
|
||||
// entry's uid, even outside the typed folders.
|
||||
db.scan();
|
||||
assert_eq!(db.uid_of("misc/brick.dat"), Some(uid));
|
||||
// A rescan does not disturb the moved entry's uid... (misc/ is not a
|
||||
// typed folder, so the entry survives only because scan never saw it —
|
||||
// move back first to prove the typed-folder case.)
|
||||
db.move_asset(uid, "textures/brick.png").unwrap();
|
||||
db.scan();
|
||||
assert_eq!(db.uid_of("textures/brick.png"), Some(uid));
|
||||
|
||||
// No-op and error cases.
|
||||
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
//! editor canvas previews).
|
||||
//!
|
||||
//! A future depth-aware mode (where a panel behind a wall is properly
|
||||
//! hidden) is in [`PLAN.md`](../../../../PLAN.md)'s Stage-8 backlog and
|
||||
//! hidden) is in the wiki Roadmap's Stage-8 backlog and
|
||||
//! slots in by attaching a depth attachment to a second pass of the
|
||||
//! same pipeline.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! focus per widget, and tell the host whether the UI captured the frame's
|
||||
//! input so the game can decide whether to also handle it.
|
||||
//!
|
||||
//! Stage 8's UI must *consume input before the game* (PLAN.md): if the
|
||||
//! Stage 8's UI must *consume input before the game* (see the wiki Roadmap): if the
|
||||
//! cursor is over a button, clicking shouldn't also fire the game-world
|
||||
//! action bound to that mouse button. The [`Router`] gives the host one
|
||||
//! object to drive each frame:
|
||||
|
||||
Reference in New Issue
Block a user