1 Commits

Author SHA1 Message Date
Homer 072a7c0b56 Documentation that points where the documentation is
The engine documentation, the staged plan, the session handoff and the project
rules are on the wiki now. What is left here is what ships with the engine:
README, LICENSE, install.sh, and this changelog.

The doc comments in ui/panel.rs, ui/routing.rs and editor/assets.rs named
PLAN.md and docs/scripting.md by path; one of them was a markdown link four
directories up, which had stopped resolving the moment the file moved. They
name the wiki page instead. Cargo.toml's platform comment and the .gitignore
note follow.

A changelog, starting now rather than reconstructed. Oxide stays at 0.1.0: the
version is already in Cargo.toml and Phase 1 runs to Stage 16, so the alpha
phase is where it actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 18:16:12 +02:00
13 changed files with 131 additions and 1280 deletions
+1 -1
View File
@@ -27,6 +27,6 @@ Thumbs.db
# Logs # Logs
*.log *.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 *.local.md
/scratch/ /scratch/
+50
View File
@@ -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 09 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 04: 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
View File
@@ -28,7 +28,7 @@ hecs = "0.10"
# Linux is the primary target on BOTH Wayland and Xorg (X11): keep both winit # 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 # backends explicitly enabled so neither can be dropped by a default-feature
# change. (On by default today; listing them makes the contract explicit — # 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"] } winit = { version = "0.30", features = ["x11", "wayland", "serde"] }
wgpu = "29" wgpu = "29"
pollster = "0.4" pollster = "0.4"
@@ -45,10 +45,6 @@ gltf = { version = "1.4", features = ["utils"] }
ab_glyph = "0.2" ab_glyph = "0.2"
# Editor UI (egui — integrated into oxide-editor only) # 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 = "0.34"
egui-wgpu = "0.34" egui-wgpu = "0.34"
egui-winit = "0.34" egui-winit = "0.34"
+21 -19
View File
@@ -13,7 +13,7 @@ stylized low-poly to realistic graphics, and shipping only what each game uses.
## Status ## Status
Oxide is built in two phases (see [PLAN.md](PLAN.md)): **Phase 1 (Stages 016)** Oxide is built in two phases (see the [Roadmap](https://git.houmeres.sk/Houmeres/Oxide/wiki/Roadmap)): **Phase 1 (Stages 016)**
is the general-purpose engine — everything needed to build and export any game; is the general-purpose engine — everything needed to build and export any game;
**Phase 2 (Stages 17+)** adds optional, feature-gated built-in modules. **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 Runnable example: `cargo run -p oxide-examples --bin ui_menu` (Stage-8
piece 7) 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:** **Phase 1 — the general-purpose engine:**
@@ -117,7 +117,7 @@ Available now:
- Rust stable toolchain (`rustup` recommended) - Rust stable toolchain (`rustup` recommended)
- Linux — primary target, on **both Wayland and Xorg (X11)**. Windows support and cross-platform - 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`) - A GPU with Vulkan or Metal support (for `wgpu`)
Install Rust if you don't have it: Install Rust if you don't have it:
@@ -208,18 +208,23 @@ cargo fmt --check
## Documentation ## Documentation
The full documentation — usage guides, per-system API references, and The full documentation — usage guides, per-system API references, and
explanations of the engine's inner workings — lives in [`docs/`](docs/README.md). explanations of the engine's inner workings — lives on the
Start there for anything beyond this overview: [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 - [Getting Started](https://git.houmeres.sk/Houmeres/Oxide/wiki/Getting-started) — build, run, test, install
- [Architecture](docs/architecture.md) — workspace and design overview - [Architecture](https://git.houmeres.sk/Houmeres/Oxide/wiki/Architecture) — workspace and design overview
- [Conventions](docs/conventions.md) — coordinate system, units, color space - [Conventions](https://git.houmeres.sk/Houmeres/Oxide/wiki/Conventions) — coordinate system, units, color space
- [Development Workflow](docs/development.md) — branches, testing, contributing - [Development Workflow](https://git.houmeres.sk/Houmeres/Oxide/wiki/Development) — branches, testing, contributing
- [Math & Core Primitives](docs/math.md) — Stage 1 API reference - [Math & Core Primitives](https://git.houmeres.sk/Houmeres/Oxide/wiki/Math) — Stage 1 API reference
- [Windowing & App Loop](docs/windowing.md) — Stage 2 window/event-loop reference - [Windowing & App Loop](https://git.houmeres.sk/Houmeres/Oxide/wiki/Windowing) — Stage 2 window/event-loop reference
- [Render Context](docs/render-context.md) — Stage 2 GPU/surface reference - [Render Context](https://git.houmeres.sk/Houmeres/Oxide/wiki/Render-context) — Stage 2 GPU/surface reference
- [Scene Graph & Entities](docs/scene.md) — Stage 3 scene/hierarchy/serialization reference - [Scene Graph & Entities](https://git.houmeres.sk/Houmeres/Oxide/wiki/Scene) — Stage 3 scene/hierarchy/serialization reference
- [Rendering](docs/rendering.md) — Stage 4 mesh/material/camera/forward-renderer 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/ Oxide/
├── assets/ # Project logos and shared assets ├── assets/ # Project logos and shared assets
├── docs/ # Full engine documentation
├── engine/ # Core engine library (oxide-engine) ├── engine/ # Core engine library (oxide-engine)
├── editor/ # In-engine editor binary (oxide-editor) ├── editor/ # In-engine editor binary (oxide-editor)
├── examples/ # Runnable stage examples (oxide-examples) ├── examples/ # Runnable stage examples (oxide-examples)
├── tests/ # Integration test harness (oxide-tests) ├── tests/ # Integration test harness (oxide-tests)
── install.sh # Build + system install script ── install.sh # Build + system install script
├── PLAN.md # Staged development roadmap
└── CLAUDE.md # Context and rules for Claude Code
``` ```
--- ---
@@ -243,7 +245,7 @@ Oxide/
## Development roadmap ## Development roadmap
Development follows a staged plan — each stage is fully tested before the next begins. 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.
--- ---
-6
View File
@@ -22,15 +22,9 @@ egui.workspace = true
egui-wgpu.workspace = true egui-wgpu.workspace = true
egui-winit.workspace = true egui-winit.workspace = true
egui_dock.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` # Editor preferences file I/O reads/writes the same RON shape `Settings`
# exports; the engine already pulls `ron` in, the editor now does too. # exports; the engine already pulls `ron` in, the editor now does too.
ron.workspace = true 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, # PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty` # REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
+1 -1
View File
@@ -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 /// The `.rhai` source the "New Script" button writes, personalised with the
/// script's file stem so the Console output identifies which script speaks. /// 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 /// `update` body ships commented out so a freshly created script visibly runs
/// (the `init` print) without moving anything until the author opts in. /// (the `init` print) without moving anything until the author opts in.
pub fn script_template(stem: &str) -> String { pub fn script_template(stem: &str) -> String {
-524
View File
@@ -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();
}
}
-1
View File
@@ -16,7 +16,6 @@ pub mod bindings;
pub mod command; pub mod command;
pub mod commands; pub mod commands;
pub mod console; pub mod console;
pub mod explorer;
pub mod extension; pub mod extension;
pub mod gizmo; pub mod gizmo;
pub mod play; pub mod play;
+43 -664
View File
@@ -39,7 +39,7 @@ use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use egui_dock::{DockArea, DockState, NodeIndex, Style}; 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::input::{Binding, InputState};
use oxide_engine::prelude::*; use oxide_engine::prelude::*;
use oxide_engine::project::{Project, ProjectError}; use oxide_engine::project::{Project, ProjectError};
@@ -52,7 +52,7 @@ use crate::command::{Command, CommandStack};
use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd}; use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd};
use crate::extension::EditorExtensions; use crate::extension::EditorExtensions;
use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis}; 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::math::{Mat4, Vec4};
use oxide_engine::reflect::FieldInfo; use oxide_engine::reflect::FieldInfo;
@@ -104,26 +104,6 @@ impl PanelKind {
/// despawn / reparent). Tracked here only because the hierarchy panel builds /// despawn / reparent). Tracked here only because the hierarchy panel builds
/// these while the UI closure runs and applies them after, the same idiom /// these while the UI closure runs and applies them after, the same idiom
/// the Stage-5 main.rs used. /// 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 { enum PendingAction {
/// Spawn a named prefab as a root entity (data-driven add-menu). The /// Spawn a named prefab as a root entity (data-driven add-menu). The
/// `Empty` prefab is a bare node; the rest carry components. /// `Empty` prefab is a bare node; the rest carry components.
@@ -543,15 +523,8 @@ pub struct Shell {
new_project_name: String, new_project_name: String,
/// Text entered in the "Open Project" modal. /// Text entered in the "Open Project" modal.
open_project_path: String, 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. /// Text entered in the "Groups" editor's "add group" field.
new_group_name: String, 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). /// The Console panel's command-input buffer (the terminal prompt).
terminal_input: String, terminal_input: String,
@@ -566,10 +539,6 @@ pub struct Shell {
/// pick) versus over an opaque panel. /// pick) versus over an opaque panel.
viewport_rect_px: Option<(f32, f32, f32, f32)>, 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 ----------------------------------------- // --- per-frame UI scratch -----------------------------------------
pending: Vec<PendingAction>, pending: Vec<PendingAction>,
rename_buf: String, rename_buf: String,
@@ -654,10 +623,7 @@ impl Shell {
new_project_path: String::new(), new_project_path: String::new(),
new_project_name: String::new(), new_project_name: String::new(),
open_project_path: String::new(), open_project_path: String::new(),
folder_pick: None,
explorer: crate::explorer::ExplorerState::default(),
new_group_name: String::new(), new_group_name: String::new(),
new_script_name: String::new(),
terminal_input: String::new(), terminal_input: String::new(),
quit_requested: false, quit_requested: false,
viewport_rect_px: None, viewport_rect_px: None,
@@ -1134,9 +1100,6 @@ impl Shell {
/// closure more than once during layout. /// closure more than once during layout.
pub fn build(&mut self, ui: &mut egui::Ui) { pub fn build(&mut self, ui: &mut egui::Ui) {
self.pending.clear(); 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.menu_bar(ui);
self.play_toolbar(ui); self.play_toolbar(ui);
@@ -1162,8 +1125,6 @@ impl Shell {
extensions: &mut self.extensions, extensions: &mut self.extensions,
pending: &mut self.pending, pending: &mut self.pending,
rename_buf: &mut self.rename_buf, rename_buf: &mut self.rename_buf,
explorer: &mut self.explorer,
new_script_name: &mut self.new_script_name,
terminal_input: &mut self.terminal_input, terminal_input: &mut self.terminal_input,
rot_euler: &mut self.rot_euler, rot_euler: &mut self.rot_euler,
euler_for: &mut self.euler_for, euler_for: &mut self.euler_for,
@@ -1498,8 +1459,6 @@ impl Shell {
// section editor lands. // section editor lands.
if name == crate::bindings::SETTINGS_SECTION { if name == crate::bindings::SETTINGS_SECTION {
ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui)); 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 { } else {
let ron = self.state.settings.section_ron(name); let ron = self.state.settings.section_ron(name);
ui.collapsing(name, |ui| match ron { ui.collapsing(name, |ui| match ron {
@@ -1523,37 +1482,6 @@ impl Shell {
self.show_preferences = open; 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 /// Renders the input-bindings preferences page: every registered editor
/// action with its current bindings, a Change/Add/Clear control per /// action with its current bindings, a Change/Add/Clear control per
/// slot, a per-action "Restore defaults" button, and a global /// slot, a per-action "Restore defaults" button, and a global
@@ -1925,80 +1853,22 @@ impl Shell {
self.show_group_editor = open; self.show_group_editor = open;
} }
/// Launches the native folder picker on a helper thread, reporting into /// In-app New Project dialog: a path field, a name field, and Create / Cancel.
/// [`Shell::folder_pick`]. One pick at a time; the Browse buttons are /// A native OS file dialog (`rfd` or similar) is a piece-6 polish item — for
/// disabled while one is up. `rfd`'s portal backend serves both Wayland /// now the path is typed, which is enough to exercise the flow end-to-end and
/// and X11; if no portal service is running the thread reports `None` /// keeps the editor dependency-light.
/// (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).
fn new_project_window(&mut self, ui: &mut egui::Ui) { fn new_project_window(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let mut open = self.show_new_project; let mut open = self.show_new_project;
let mut create_now = false; let mut create_now = false;
let mut cancel_now = false; let mut cancel_now = false;
let mut browse_now = false;
let picking = self.folder_pick.is_some();
egui::Window::new("New Project") egui::Window::new("New Project")
.open(&mut open) .open(&mut open)
.default_size([520.0, 160.0]) .default_size([520.0, 160.0])
.resizable(true) .resizable(true)
.show(&ctx, |ui| { .show(&ctx, |ui| {
ui.label("Project folder"); ui.label("Project folder");
ui.horizontal(|ui| { ui.text_edit_singleline(&mut self.new_project_path);
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.label("Display name");
ui.text_edit_singleline(&mut self.new_project_name); ui.text_edit_singleline(&mut self.new_project_name);
ui.add_space(6.0); ui.add_space(6.0);
@@ -2017,12 +1887,6 @@ impl Shell {
project.oxide.", 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 { if create_now {
let path = PathBuf::from(self.new_project_path.trim()); let path = PathBuf::from(self.new_project_path.trim());
let name = self.new_project_name.trim().to_owned(); 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, /// In-app Open Project dialog: one path field, Open / Cancel.
/// Open / Cancel.
fn open_project_window(&mut self, ui: &mut egui::Ui) { fn open_project_window(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone(); let ctx = ui.ctx().clone();
let mut open = self.show_open_project; let mut open = self.show_open_project;
let mut open_now = false; let mut open_now = false;
let mut cancel_now = false; let mut cancel_now = false;
let mut browse_now = false;
let picking = self.folder_pick.is_some();
egui::Window::new("Open Project") egui::Window::new("Open Project")
.open(&mut open) .open(&mut open)
.default_size([520.0, 140.0]) .default_size([520.0, 140.0])
.resizable(true) .resizable(true)
.show(&ctx, |ui| { .show(&ctx, |ui| {
ui.label("Project folder (or path to project.oxide)"); ui.label("Project folder (or path to project.oxide)");
ui.horizontal(|ui| { ui.text_edit_singleline(&mut self.open_project_path);
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.add_space(6.0);
ui.horizontal(|ui| { ui.horizontal(|ui| {
let valid = !self.open_project_path.trim().is_empty(); 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 { if open_now {
let path = PathBuf::from(self.open_project_path.trim()); let path = PathBuf::from(self.open_project_path.trim());
match self.open_project(path) { match self.open_project(path) {
@@ -2312,12 +2154,6 @@ struct ShellTabViewer<'a> {
extensions: &'a mut EditorExtensions, extensions: &'a mut EditorExtensions,
pending: &'a mut Vec<PendingAction>, pending: &'a mut Vec<PendingAction>,
rename_buf: &'a mut String, 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). /// The Console command-input buffer (the terminal prompt).
terminal_input: &'a mut String, terminal_input: &'a mut String,
rot_euler: &'a mut Vec3, rot_euler: &'a mut Vec3,
@@ -3528,21 +3364,6 @@ impl<'a> ShellTabViewer<'a> {
edits.push((comp.name, row.info.name, new_ron)); 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 /// Renders one reflected field as a typed widget chosen from its
/// `type_name`, returning the field's new RON if the user changed it. /// `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 /// 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.monospace(project.root().display().to_string());
ui.separator(); ui.separator();
// File explorer over assets/ (Stage-10 editor-UX): breadcrumbs, // Asset browser: typed folders grouped by kind, listed from the
// folder navigation, rename/delete/move, and OS drag-in import — all // database (the source of truth for what the picker can reference).
// over the AssetDatabase so every operation keeps uids (and saved // "Importing" an asset is just dropping the file into the right folder;
// AssetRefs) intact. Importing also still works by dropping a file // the file watcher rescans, or the user can rescan on demand.
// into assets/ on disk; the watcher or Rescan registers it.
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Assets"); ui.label("Assets");
if ui if ui
@@ -4021,16 +3721,13 @@ impl<'a> ShellTabViewer<'a> {
let _ = db.save(); 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(); ui.separator();
let project = self.state.project.as_ref().unwrap(); let project = self.state.project.as_ref().unwrap();
@@ -4038,325 +3735,6 @@ impl<'a> ShellTabViewer<'a> {
show_project_tree(ui, "scripts/", project.scripts_dir()); 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, /// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview,
/// and a property panel (including the font-asset picker) — saved as a /// and a property panel (including the font-asset picker) — saved as a
/// `ui/` asset. Structural and property edits route through /// `ui/` asset. Structural and property edits route through
@@ -4937,29 +4315,6 @@ impl<'a> ShellTabViewer<'a> {
// ---- helpers ----------------------------------------------------------- // ---- 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. /// Returns whether the rendered control changed any axis this frame.
/// Projects a world-space point through `view_proj` to screen pixels /// Projects a world-space point through `view_proj` to screen pixels
/// inside `tab_rect` (egui logical points). Returns `None` when the point /// 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 /// The colour for a console log line of the given severity: errors red, warnings
/// amber, info the normal text colour, debug/trace dimmed. /// amber, info the normal text colour, debug/trace dimmed.
fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 { fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 {
@@ -5434,6 +4793,26 @@ fn build_terminal_job(
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, /// A short label for a widget in the tree / property header: its id if set,
/// else its kind, with the root marked. /// else its kind, with the root marked.
fn widget_label(widget: &Widget, path: &WidgetPath) -> String { fn widget_label(widget: &Widget, path: &WidgetPath) -> String {
-28
View File
@@ -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 { impl EditorState {
/// A blank state with an empty scene, no open project, and the editor's /// A blank state with an empty scene, no open project, and the editor's
/// default action bindings registered (`F` toggle, WASD/QE move, Shift /// default action bindings registered (`F` toggle, WASD/QE move, Shift
@@ -238,7 +224,6 @@ impl EditorState {
bindings::register_defaults(&mut actions); bindings::register_defaults(&mut actions);
let mut settings = Settings::new(); let mut settings = Settings::new();
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION); settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
settings.register::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION);
let mut registry = TypeRegistry::new(); let mut registry = TypeRegistry::new();
register_builtin_types(&mut registry); register_builtin_types(&mut registry);
// Seed a small, generally-useful set of named layers (besides the // Seed a small, generally-useful set of named layers (besides the
@@ -605,17 +590,4 @@ mod tests {
.get::<oxide_physics::RigidBody>(restored) .get::<oxide_physics::RigidBody>(restored)
.is_some()); .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)"
);
}
} }
+12 -29
View File
@@ -440,21 +440,18 @@ impl AssetDatabase {
uid uid
} }
/// Scans everything under `assets/` and reconciles the database with what /// Scans the typed asset folders under `assets/` and reconciles the
/// is on disk: existing files keep their uid, new files are /// database with what is on disk: existing files keep their uid, new files
/// [registered](Self::register), and entries whose files no longer exist /// are [registered](Self::register), and entries whose files no longer exist
/// are dropped. Returns the number of newly registered assets. /// 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. /// Call [`save`](Self::save) afterwards to persist any new uids.
pub fn scan(&mut self) -> usize { pub fn scan(&mut self) -> usize {
let assets_dir = self.assets_dir(); let assets_dir = self.assets_dir();
let mut found: Vec<String> = Vec::new(); 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. // Drop entries whose backing file disappeared.
let present: std::collections::HashSet<&String> = found.iter().collect(); let present: std::collections::HashSet<&String> = found.iter().collect();
@@ -814,27 +811,12 @@ mod tests {
assert_eq!(db.len(), 3); assert_eq!(db.len(), 3);
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1); 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. // Remove one file and rescan: it is pruned, the rest keep their uids.
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap(); let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
let wall_uid = db.uid_of("textures/wall.png").unwrap(); let wall_uid = db.uid_of("textures/wall.png").unwrap();
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap(); std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
assert_eq!(db.scan(), 0); 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!(db.entry(font_uid).is_none());
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid)); 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(); db.move_asset(uid, "misc/brick.dat").unwrap();
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other); assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
// A rescan (which walks the whole tree) does not disturb the moved // A rescan does not disturb the moved entry's uid... (misc/ is not a
// entry's uid, even outside the typed folders. // typed folder, so the entry survives only because scan never saw it —
db.scan(); // move back first to prove the typed-folder case.)
assert_eq!(db.uid_of("misc/brick.dat"), Some(uid));
db.move_asset(uid, "textures/brick.png").unwrap(); 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. // No-op and error cases.
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok
+1 -1
View File
@@ -55,7 +55,7 @@
//! editor canvas previews). //! editor canvas previews).
//! //!
//! A future depth-aware mode (where a panel behind a wall is properly //! 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 //! slots in by attaching a depth attachment to a second pass of the
//! same pipeline. //! same pipeline.
+1 -1
View File
@@ -2,7 +2,7 @@
//! focus per widget, and tell the host whether the UI captured the frame's //! 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. //! 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 //! 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 //! action bound to that mouse button. The [`Router`] gives the host one
//! object to drive each frame: //! object to drive each frame: