Files
Oxide/editor/src/shell.rs
T
Homer c47efa876f Editor: Unity-style file explorer in the Project panel
The last item of the Stage-10 editor-UX batch. The Project panel's
fixed typed-folder listing becomes a real file explorer over assets/:

- breadcrumbs + double-click folder navigation, " New Folder", inline
  rename rows, context menus (Open / Rename / Delete), drag a row onto
  a folder (or "..") to move it, drag files in from the OS to import
  into the current folder, double-click to open (scripts via the
  external-editor flow, others via xdg-open).
- All behavior lives egui-free in editor/src/explorer.rs (listing,
  breadcrumbs, name validation/uniquing, create/rename/move/delete/
  import) and is unit-tested; the shell only renders it. Renames and
  moves ride the uid-preserving AssetDatabase ops so saved AssetRefs
  keep resolving; unregistered files fall back to fs::rename. Folders
  delete only when empty — no recursive asset deletion.
- Engine: AssetDatabase::scan now walks the WHOLE assets/ tree instead
  of just the typed folders, so assets organised into custom folders
  register and survive rescans (covered by updated unit tests).

File operations act immediately and bypass the undo stack, like the
hierarchy's structural edits. GUI piece — needs an eye-check before
promotion to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:01:05 +02:00

6664 lines
266 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The editor's docking shell.
//!
//! Owns the top **menu bar**, the **dockable panel layout** (built on
//! [`egui_dock`]), the **status bar**, and the **Preferences** window. The
//! shell is the host every other Stage-6 piece plugs into:
//!
//! - The Stage-6 [command stack](crate::command) drives the Edit menu and
//! `Ctrl+Z` / `Ctrl+Y`.
//! - The Stage-6 [project system](oxide_engine::project) drives the
//! File / Project menus (New / Open / Save).
//! - The Stage-6 [settings framework](oxide_engine::settings) drives the
//! Preferences window.
//! - The Stage-6 [file watcher](oxide_engine::watch) is started when a
//! project opens, and its events feed
//! [`reload_changed_assets`](oxide_engine::watch::reload_changed_assets)
//! each frame.
//! - The Stage-6 [extension API](crate::extension) provides module-
//! contributed menu items, panels, and settings pages, hosted alongside
//! the built-in ones.
//!
//! ## Why a registry, not hard-coded panels
//!
//! The built-in panels (Hierarchy, Inspector, Viewport, Project, Console)
//! are dispatched by an enum [`PanelKind`] and rendered in the shell itself,
//! because they need direct access to [`EditorState`] (scene, selection, asset
//! server, project). Module-contributed panels go through the
//! [`EditorExtensions`](crate::extension::EditorExtensions) registry; their
//! `FnMut(&mut egui::Ui)` closure can capture module-owned state, and the
//! shell renders them as additional tabs in the same dock.
//!
//! Piece-6 scope: ship the shell with built-in panels at feature parity with
//! Stage-5's egui::Panel layout, the Edit-menu undo/redo wired to scene
//! mutations, the file watcher pumping into asset reload on project open, and
//! a Preferences window listing the registered settings sections and the
//! enabled modules. Visual polish and richer settings editors land
//! incrementally in later stages.
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::input::{Binding, InputState};
use oxide_engine::prelude::*;
use oxide_engine::project::{Project, ProjectError};
use oxide_engine::scene::{DespawnPolicy, DisabledComponents};
use oxide_engine::watch::{reload_changed_assets, ChangeEvent, FileWatcher};
use oxide_engine::winit::event::MouseButton;
use oxide_engine::winit::keyboard::KeyCode;
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 oxide_engine::math::{Mat4, Vec4};
use oxide_engine::reflect::FieldInfo;
/// Identifies one panel inside the dock. Built-in panels are explicit
/// variants; module-contributed panels carry their registered name in
/// [`Custom`](PanelKind::Custom).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum PanelKind {
/// Scene hierarchy tree.
Hierarchy,
/// Selection-bound inspector / properties.
Inspector,
/// The 3D viewport. The tab itself draws nothing — the host renders the
/// 3D scene directly to the surface before egui composites, and this tab
/// suppresses its own background (`clear_background` returns `false`) so
/// the 3D content shows through where the tab is. The other panels keep
/// opaque backgrounds and occlude the surrounding 3D area.
Viewport,
/// Project file browser (a tree over `assets/`, `scenes/`, `scripts/`).
Project,
/// Visual UI-document builder (widget tree + canvas preview + properties).
UiCanvas,
/// Log / status console + non-interactive command runner.
Console,
/// Interactive PTY terminal — runs shells / TUIs / AI-agent CLIs.
Terminal,
/// A panel contributed by a module through
/// [`EditorExtensions::add_panel`](crate::extension::EditorExtensions::add_panel).
Custom(String),
}
impl PanelKind {
/// Human-readable title for the dock tab.
pub fn title(&self) -> &str {
match self {
PanelKind::Hierarchy => "Hierarchy",
PanelKind::Inspector => "Inspector",
PanelKind::Viewport => "Viewport",
PanelKind::Project => "Project",
PanelKind::UiCanvas => "UI Canvas",
PanelKind::Console => "Console",
PanelKind::Terminal => "Terminal",
PanelKind::Custom(name) => name,
}
}
}
/// A structural scene edit that bypasses the command stack today (spawn /
/// 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.
AddRootPrefab(String),
/// Spawn a named prefab as a child of `entity`.
AddChildPrefab(Entity, String),
Delete(Entity),
/// Duplicate an entity as a sibling, copying its registered components
/// (via the reflection registry). Children are not duplicated yet.
Duplicate(Entity),
SetEnabled(Entity, bool),
// --- Bindings preferences page (Stage 7 piece 5) ------------------
/// Restore every editor action to its defaults.
RestoreAllBindings,
/// Restore one action to its defaults (button or axis or 2D axis —
/// the [`ActionMap`] dispatches by name across kinds).
RestoreActionDefaults(String),
/// Drop one binding from a button action's current list.
RemoveButtonBinding {
action: String,
index: usize,
},
/// Drop one binding from a direction-set of an axis (1D or 2D).
RemoveAxisBinding {
action: String,
dir: AxisDirection,
index: usize,
},
}
/// Discriminator used by the bindings UI to talk about "one direction of an
/// axis" without caring whether the action is 1D or 2D. Converted to a
/// [`CaptureTarget`] when capture begins and back to the right axis edit
/// when applying pending actions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisDirection {
Axis(AxisSide),
Axis2D(Axis2DSide),
}
impl AxisDirection {
/// Short label shown next to the direction row in the bindings UI.
fn label(&self) -> &'static str {
match self {
AxisDirection::Axis(AxisSide::Positive) => "+",
AxisDirection::Axis(AxisSide::Negative) => "",
AxisDirection::Axis2D(Axis2DSide::Right) => "→",
AxisDirection::Axis2D(Axis2DSide::Left) => "←",
AxisDirection::Axis2D(Axis2DSide::Up) => "↑",
AxisDirection::Axis2D(Axis2DSide::Down) => "↓",
}
}
}
/// One frame's data needed to paint the gizmo handles over the Viewport
/// tab. The host (binary) sets it before [`Shell::build`] runs; the Shell
/// stashes it on a field that the Viewport tab reads.
///
/// Decoupled from the gizmo *math* in [`crate::gizmo`] so the math stays
/// pure and the renderer can change (egui overlay today, native 3D mesh
/// in a later piece) without touching either side.
#[derive(Debug, Clone, Copy)]
pub struct ViewportOverlay {
/// The view-projection matrix the renderer drew the scene with — used
/// to project handle world points to screen pixels here.
pub view_proj: Mat4,
/// World-space length of axis arrows / circles / cubes. Same value
/// `gizmo::hit_test` was called with, so what the user sees matches
/// where clicks land.
pub gizmo_size: f32,
}
/// A frozen raycast-probe visualization the Viewport tab paints when **View ▸
/// Raycast Probe** is on (Stage 9 piece 8c).
///
/// On a viewport click the host casts the editor camera→cursor ray against the
/// edited scene's colliders (via
/// [`PhysicsWorld::sync_to_scene`](oxide_physics::PhysicsWorld::sync_to_scene) +
/// [`raycast`](oxide_physics::PhysicsWorld::raycast)) and **freezes** the result
/// here. The tab redraws it in world space every frame, so orbiting the camera
/// reveals the ray as a real 3D line — a ray cast from the live camera is
/// otherwise just a point in that same camera's view. Like [`ViewportOverlay`]
/// it is `Copy` and projected with the same view-projection the scene was drawn
/// with.
#[derive(Debug, Clone, Copy)]
pub struct RaycastProbeViz {
/// World-space ray origin (the camera eye-point under the cursor).
pub origin: Vec3,
/// World-space end of the drawn ray: the hit point on a hit, else the ray
/// extended to its probe distance on a miss.
pub end: Vec3,
/// The surface the ray struck, if any.
pub hit: Option<RaycastProbeHit>,
}
/// The surface a [`RaycastProbeViz`] ray struck.
#[derive(Debug, Clone, Copy)]
pub struct RaycastProbeHit {
/// World-space hit point on the collider surface.
pub point: Vec3,
/// World-space unit surface normal at the hit.
pub normal: Vec3,
}
/// Builds a [`CaptureTarget`] for one direction row of an axis action.
fn make_axis_capture(action: &str, dir: AxisDirection, slot: BindingSlot) -> CaptureTarget {
match dir {
AxisDirection::Axis(side) => CaptureTarget::Axis {
action: action.to_string(),
side,
slot,
},
AxisDirection::Axis2D(side) => CaptureTarget::Axis2D {
action: action.to_string(),
side,
slot,
},
}
}
/// Single flat status-bar line and how long it remains visible.
struct StatusLine {
text: String,
/// Wall-clock frames remaining (decremented in `frame_tick`). 0 = hidden.
ttl: u32,
}
impl StatusLine {
fn idle() -> Self {
Self {
text: String::new(),
ttl: 0,
}
}
fn say(&mut self, text: impl Into<String>) {
self.text = text.into();
// ~5 seconds at 60 FPS — short-lived but readable.
self.ttl = 300;
}
}
/// Which slot of an action's binding list the user is currently rebinding.
///
/// The bindings preferences page enters one of these states when the user
/// clicks a "Change" / "Add" button; the host runner consumes the next
/// pressed key or mouse button into that slot via
/// [`Shell::try_complete_capture`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CaptureTarget {
/// One of a button action's bindings (`Replace` an existing index, or
/// `Append` a new binding to the list).
Button { action: String, slot: BindingSlot },
/// One of a 1D-axis action's direction-set bindings.
Axis {
action: String,
side: AxisSide,
slot: BindingSlot,
},
/// One of a 2D-axis action's four direction-set bindings.
Axis2D {
action: String,
side: Axis2DSide,
slot: BindingSlot,
},
}
/// Whether a capture replaces an existing binding at a given index, or
/// appends a new one to the slot's binding list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindingSlot {
Replace(usize),
Append,
}
/// The two directions of a 1D axis (positive = right/forward/up).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisSide {
Positive,
Negative,
}
/// The four direction-sets of a 2D axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis2DSide {
Right,
Left,
Up,
Down,
}
impl CaptureTarget {
/// The action name this capture targets — used by status messages and
/// the bindings UI to label the in-progress capture.
pub fn action(&self) -> &str {
match self {
CaptureTarget::Button { action, .. }
| CaptureTarget::Axis { action, .. }
| CaptureTarget::Axis2D { action, .. } => action.as_str(),
}
}
}
/// Every key/mouse-button the bindings preferences page will offer to bind
/// when the user clicks "Change". Curated to keep the UI scrollbar humane;
/// the underlying `ActionMap` accepts every `KeyCode`/`MouseButton` so the
/// list can be extended without breaking the model. Iteration order is the
/// order shown in the UI.
const BINDABLE_KEYS: &[KeyCode] = &[
// Letters
KeyCode::KeyA,
KeyCode::KeyB,
KeyCode::KeyC,
KeyCode::KeyD,
KeyCode::KeyE,
KeyCode::KeyF,
KeyCode::KeyG,
KeyCode::KeyH,
KeyCode::KeyI,
KeyCode::KeyJ,
KeyCode::KeyK,
KeyCode::KeyL,
KeyCode::KeyM,
KeyCode::KeyN,
KeyCode::KeyO,
KeyCode::KeyP,
KeyCode::KeyQ,
KeyCode::KeyR,
KeyCode::KeyS,
KeyCode::KeyT,
KeyCode::KeyU,
KeyCode::KeyV,
KeyCode::KeyW,
KeyCode::KeyX,
KeyCode::KeyY,
KeyCode::KeyZ,
// Top row
KeyCode::Digit0,
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
KeyCode::Digit7,
KeyCode::Digit8,
KeyCode::Digit9,
// Whitespace + arrows + modifiers
KeyCode::Space,
KeyCode::Tab,
KeyCode::Enter,
KeyCode::Backspace,
KeyCode::ArrowLeft,
KeyCode::ArrowRight,
KeyCode::ArrowUp,
KeyCode::ArrowDown,
KeyCode::ShiftLeft,
KeyCode::ShiftRight,
KeyCode::ControlLeft,
KeyCode::ControlRight,
KeyCode::AltLeft,
KeyCode::AltRight,
// Function keys
KeyCode::F1,
KeyCode::F2,
KeyCode::F3,
KeyCode::F4,
KeyCode::F5,
KeyCode::F6,
KeyCode::F7,
KeyCode::F8,
KeyCode::F9,
KeyCode::F10,
KeyCode::F11,
KeyCode::F12,
];
/// The mouse buttons offered as bindings. `Left` is excluded by default so
/// the click that initiated a capture cannot accidentally bind itself
/// (egui's button uses left-click and the runner pumps that into
/// `InputState` before the capture state machine sees the next frame —
/// excluding it removes the whole class of footguns).
const BINDABLE_MOUSE_BUTTONS: &[MouseButton] = &[
MouseButton::Right,
MouseButton::Middle,
MouseButton::Back,
MouseButton::Forward,
];
/// Scans `input` for the first pressed-this-frame key or mouse button (in
/// curated order) and wraps it as a [`Binding`]. Returns `None` when
/// nothing in the curated list was newly pressed this frame. Escape is
/// **not** treated as bindable — the capture state machine reserves it
/// for "cancel".
fn pick_capture_binding(input: &InputState) -> Option<Binding> {
for &key in BINDABLE_KEYS {
if input.pressed(key) {
return Some(Binding::Key(key));
}
}
for &button in BINDABLE_MOUSE_BUTTONS {
if input.mouse_pressed(button) {
return Some(Binding::Mouse(button));
}
}
None
}
/// Applies a captured `binding` to the target slot of the appropriate
/// binding list, mutating `actions` in place. No-op when the action is
/// unregistered (the UI is built against `actions`, so this should not
/// happen in practice).
fn apply_capture(
actions: &mut oxide_engine::input::ActionMap,
target: &CaptureTarget,
binding: Binding,
) {
match target {
CaptureTarget::Button { action, slot } => {
let mut bindings = actions.bindings(action).to_vec();
apply_to_list(&mut bindings, *slot, binding);
actions.set_bindings(action, bindings);
}
CaptureTarget::Axis { action, side, slot } => {
let Some(current) = actions.axis_bindings(action) else {
return;
};
let mut next = current.clone();
apply_to_list(side_list_mut(&mut next, *side), *slot, binding);
actions.set_axis_bindings(action, next);
}
CaptureTarget::Axis2D { action, side, slot } => {
let Some(current) = actions.axis_2d_bindings(action) else {
return;
};
let mut next = current.clone();
apply_to_list(side_2d_list_mut(&mut next, *side), *slot, binding);
actions.set_axis_2d_bindings(action, next);
}
}
}
fn apply_to_list(list: &mut Vec<Binding>, slot: BindingSlot, binding: Binding) {
match slot {
BindingSlot::Replace(i) if i < list.len() => list[i] = binding,
BindingSlot::Replace(_) | BindingSlot::Append => list.push(binding),
}
}
fn side_list_mut(axis: &mut oxide_engine::input::AxisBinding, side: AxisSide) -> &mut Vec<Binding> {
match side {
AxisSide::Positive => &mut axis.positive,
AxisSide::Negative => &mut axis.negative,
}
}
fn side_2d_list_mut(
axis: &mut oxide_engine::input::Axis2DBinding,
side: Axis2DSide,
) -> &mut Vec<Binding> {
match side {
Axis2DSide::Right => &mut axis.x.positive,
Axis2DSide::Left => &mut axis.x.negative,
Axis2DSide::Up => &mut axis.y.positive,
Axis2DSide::Down => &mut axis.y.negative,
}
}
/// Human-readable label for a binding (used in the UI and status messages).
fn describe_binding(b: &Binding) -> String {
match b {
Binding::Key(k) => format!("{k:?}"),
Binding::Mouse(m) => format!("Mouse({m:?})"),
}
}
/// The editor's docking shell.
///
/// Owns the dock state, the command stack, the extension registry, and the
/// transient UI state (dialog open flags, edit buffers, file-watcher events
/// queued for the next frame). [`EditorState`] is the heap of mutable runtime
/// data that commands act on; the shell holds it.
pub struct Shell {
/// Runtime state commands mutate (scene, selection, assets, settings,
/// project).
pub state: EditorState,
/// Layout of every dockable panel. Persisted layouts land in a later
/// piece; today the default is built in [`Shell::default_dock`].
dock: DockState<PanelKind>,
/// Editor-wide undo/redo. Cleared on project open; bounded capacity.
commands: CommandStack<EditorState>,
/// Module-contributed UI (menu items, panels, settings pages…). Populated
/// by the host before [`build`](Self::build) runs.
pub extensions: EditorExtensions,
/// Active file watcher (if a project is open) and its event receiver. The
/// shell pumps the receiver each frame in [`frame_tick`](Self::frame_tick).
watcher: Option<FileWatcher>,
watcher_events: Option<std::sync::mpsc::Receiver<ChangeEvent>>,
// --- dialog flags --------------------------------------------------
show_preferences: bool,
show_about: bool,
show_new_project: bool,
show_open_project: bool,
/// The "Edit layer names" modal — opened from the `Layer` dropdown so the
/// project can name layers `Player`, `Enemy`, `World`, … instead of bare
/// numeric indices.
show_layer_editor: bool,
/// The "Groups" modal — opened from the `Groups` dropdown so the project
/// can define the gameplay groups an entity may be tagged with.
show_group_editor: bool,
// --- modal scratch -------------------------------------------------
/// Text entered in the "New Project" modal (path + display name).
new_project_path: String,
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,
/// File → Quit sets this; the host polls and calls `request_exit` on
/// the next `update`. egui's own viewport-close command doesn't reach
/// our winit runner, so the round-trip lives here.
quit_requested: bool,
/// Screen-space rect of the Viewport tab from the most recent UI build,
/// in physical pixels. The host queries this to decide whether the
/// cursor is over the 3D viewport (and thus should drive orbit/pan/zoom/
/// 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,
/// Euler-degrees buffer for the inspector's quaternion fields. A `Quat`
/// field is edited as Euler angles (raw quaternions are unusable by hand);
/// the buffer holds the in-progress angles and is re-synced from the stored
/// quaternion only when the edited field changes (so the displayed angles
/// don't jump mid-edit from lossy quat↔euler round-trips). Keyed by which
/// `(entity, type, field)` it currently mirrors.
rot_euler: Vec3,
euler_for: Option<(Entity, &'static str, &'static str)>,
/// Most recently seen status text + remaining frames.
status: StatusLine,
/// Set by the bindings preferences page when the user clicks "Change" /
/// "Add". The host polls [`Shell::try_complete_capture`] each frame and
/// consumes the next pressed key/button into the selected slot.
capture: Option<CaptureTarget>,
/// Set to `true` whenever the input bindings change so the host can
/// flush the editor preferences file to disk. Cleared by
/// [`Shell::take_bindings_dirty`].
bindings_dirty: bool,
/// Per-frame projection data the host feeds in before [`build`](Self::build)
/// runs, so the Viewport tab can paint the gizmo overlay using the same
/// math the renderer drew with. `None` means no overlay this frame.
viewport_overlay: Option<ViewportOverlay>,
/// Set by the play toolbar's **Step** button while paused; the host polls
/// [`take_step_request`](Self::take_step_request) once per frame and, if set,
/// advances the play [`App`](oxide_engine::app::App) exactly one fixed tick.
/// One-shot: a single click steps once.
step_requested: bool,
/// View ▸ Show Colliders: when set, the Viewport tab paints a wireframe
/// outline of every entity's physics `Collider` (Stage 9 piece 8b). On by
/// default — the usual DCC convention so colliders are visible the moment
/// one is added.
show_colliders: bool,
/// View ▸ Raycast Probe: when on, a viewport click casts the editor
/// camera→cursor ray against the scene's colliders and **freezes** the
/// result into the world (Stage 9 piece 8c). Off by default — a debug aid
/// to verify the raycast API and inspect colliders.
raycast_probe: bool,
/// The frozen probe ray, drawn by the Viewport tab every frame in world
/// space (so orbiting the camera reveals it as a real 3D line). Set on a
/// probe click via [`set_raycast_probe_viz`](Self::set_raycast_probe_viz);
/// cleared when the probe is toggled off. `None` = nothing cast yet.
raycast_probe_viz: Option<RaycastProbeViz>,
/// The interactive PTY terminal sessions shown as tabs in the Terminal panel
/// (a shell, an AI-agent CLI, …). Empty until one is launched; a session is
/// dropped (which kills its child) when closed or when its program exits.
terminals: Vec<crate::pty::PtyTerminal>,
/// Index of the active terminal tab within [`terminals`](Self::terminals).
active_terminal: usize,
}
impl Shell {
/// A shell with a starter scene and the default dock layout.
pub fn new() -> Self {
Self::from_state(EditorState::with_scene(starter_scene()))
}
/// A shell wrapping the given state (useful for tests).
pub fn from_state(state: EditorState) -> Self {
Self {
state,
dock: Self::default_dock(),
commands: CommandStack::with_capacity(256),
extensions: EditorExtensions::new(),
watcher: None,
watcher_events: None,
show_preferences: false,
show_about: false,
show_new_project: false,
show_open_project: false,
show_layer_editor: false,
show_group_editor: false,
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,
pending: Vec::new(),
rename_buf: String::new(),
rot_euler: Vec3::ZERO,
euler_for: None,
status: StatusLine::idle(),
capture: None,
bindings_dirty: false,
viewport_overlay: None,
step_requested: false,
show_colliders: true,
raycast_probe: false,
raycast_probe_viz: None,
terminals: Vec::new(),
active_terminal: 0,
}
}
/// Sets the per-frame data the Viewport tab needs to paint the gizmo
/// overlay. The host calls this before [`build`](Self::build) each
/// frame; pass `None` to suppress the overlay (e.g. while a modal is
/// open).
pub fn set_viewport_overlay(&mut self, overlay: Option<ViewportOverlay>) {
self.viewport_overlay = overlay;
}
/// Whether **View ▸ Raycast Probe** is on. The host polls this each frame
/// and, when set, computes the probe and feeds it back via
/// [`set_raycast_probe_viz`](Self::set_raycast_probe_viz).
pub fn raycast_probe_enabled(&self) -> bool {
self.raycast_probe
}
/// Stores the raycast-probe result for the Viewport tab to paint this frame
/// (set by the host; `None` clears it).
pub fn set_raycast_probe_viz(&mut self, viz: Option<RaycastProbeViz>) {
self.raycast_probe_viz = viz;
}
/// Returns whether the user has asked to quit since the last call, and
/// clears the flag. The host runner calls this in `update` and forwards
/// to `AppCtx::request_exit`.
pub fn take_quit_request(&mut self) -> bool {
std::mem::take(&mut self.quit_requested)
}
/// Sets the status-bar hint to `text` for a short fade-out. The host
/// uses this to surface non-shell events (e.g. the camera-mode toggle)
/// in the same place as undo/redo and save messages.
pub fn set_status_hint(&mut self, text: impl Into<String>) {
self.status.say(text);
}
// --- Play-mode controls (Stage 8.7) -----------------------------------
/// Whether the user asked for a single **Step** since the last call, and
/// clears the flag. The host polls this each frame and, while paused,
/// advances the play [`App`](oxide_engine::app::App) one fixed tick.
pub fn take_step_request(&mut self) -> bool {
std::mem::take(&mut self.step_requested)
}
/// Enters **Play**: snapshots the scene and starts running it. Clears the
/// undo history so play-mode edits never leak into edit-mode undo (the scene
/// is restored wholesale on Stop). No-op if already playing or paused.
pub fn play(&mut self) {
if self.state.is_in_play() {
return;
}
self.state.enter_play();
self.commands.clear();
self.status.say("Playing");
}
/// The Play/Resume button's action: starts play while editing, or resumes
/// (un-pauses) while paused. A no-op while already playing. This is distinct
/// from [`play`](Self::play), which only ever *starts* play — calling it
/// while paused does nothing, which is why Resume must route through here.
pub fn play_or_resume(&mut self) {
match self.state.play {
PlayState::Editing => self.play(),
PlayState::Paused => self.toggle_pause(),
PlayState::Playing => {}
}
}
/// Toggles **Pause** while in play. No-op while editing.
pub fn toggle_pause(&mut self) {
if !self.state.is_in_play() {
return;
}
self.state.toggle_pause();
let label = if self.state.play == PlayState::Paused {
"Paused"
} else {
"Playing"
};
self.status.say(label);
}
/// Requests a single fixed-step **Step**. Only meaningful while paused; the
/// host honours it through [`take_step_request`](Self::take_step_request).
pub fn request_step(&mut self) {
if self.state.play == PlayState::Paused {
self.step_requested = true;
self.status.say("Step");
}
}
/// **Stops** play and restores the scene to its pre-play snapshot. Clears the
/// undo history (the restore is not itself undoable). No-op while editing.
pub fn stop(&mut self) {
if !self.state.is_in_play() {
return;
}
if let Err(err) = self.state.stop() {
log::error!("failed to restore scene on Stop: {err}");
self.status.say(format!("Stop: restore failed: {err}"));
} else {
self.status.say("Stopped");
}
self.commands.clear();
}
// --- Input-bindings capture API (Stage 7 piece 5) ---------------------
/// Whether the bindings preferences page is currently waiting on the
/// next key/button press to bind. The host runner uses this to gate
/// other input handling (e.g. the camera mode toggle) while a capture
/// is in flight, so the same press cannot do two things.
pub fn capture_active(&self) -> bool {
self.capture.is_some()
}
/// The target of the current capture, if any. The bindings UI uses this
/// to render the in-progress slot differently ("Press a key…").
pub fn current_capture(&self) -> Option<&CaptureTarget> {
self.capture.as_ref()
}
/// Drives the [bindings preferences page](Self::capture_active) into a
/// "waiting for next key/button" state. Called by the page's UI when the
/// user clicks Change/Add.
pub fn begin_capture(&mut self, target: CaptureTarget) {
self.capture = Some(target);
}
/// Cancels any in-progress capture without binding anything.
pub fn cancel_capture(&mut self) {
if self.capture.take().is_some() {
self.status.say("Binding capture cancelled");
}
}
/// Looks at `input` and, if a key or non-`Escape` mouse button became
/// pressed this frame, commits it as the binding for the currently-
/// captured slot. `Escape` cancels capture without binding.
///
/// Returns whether the capture finished this call (either committed or
/// cancelled) so the host can drop other input handling for the rest of
/// the frame.
pub fn try_complete_capture(&mut self, input: &InputState) -> bool {
if self.capture.is_none() {
return false;
}
if input.pressed(KeyCode::Escape) {
self.cancel_capture();
return true;
}
let Some(binding) = pick_capture_binding(input) else {
return false;
};
// `take` unwrap is safe — guarded by the early-return above.
let target = self.capture.take().unwrap();
apply_capture(&mut self.state.actions, &target, binding);
self.state.sync_action_overrides_to_settings();
self.bindings_dirty = true;
self.status.say(format!(
"Bound {}{}",
target.action(),
describe_binding(&binding)
));
true
}
/// Reads-and-clears the bindings-dirty flag. The host calls this each
/// frame and writes the editor preferences file when it returns `true`.
pub fn take_bindings_dirty(&mut self) -> bool {
std::mem::take(&mut self.bindings_dirty)
}
/// Pushes `command` onto the shell's undo stack with `EditorState` as
/// its context. The hosting binary uses this to record changes it made
/// directly to the scene (e.g. ending a gizmo drag) so the same Ctrl+Z
/// rolls them back as if the user had used the inspector.
pub fn push_command(&mut self, command: impl Command<EditorState> + 'static) {
self.commands.push(command, &mut self.state);
// Rotation buffers, etc. don't necessarily match the new transform.
self.euler_for = None;
}
/// Restores every editor action to its code-defined defaults and flips
/// the dirty flag. Called by the "Restore all defaults" button.
pub fn restore_default_bindings(&mut self) {
self.state.actions.restore_all_defaults();
self.state.sync_action_overrides_to_settings();
self.bindings_dirty = true;
self.status.say("Restored default bindings");
}
/// Whether the physical-pixel cursor lies inside the Viewport tab's
/// most-recent rect — i.e. over the visible 3D scene rather than an
/// opaque panel. egui marks the whole tab area as "consumed" because
/// the tab is technically an egui widget; the host uses this to override
/// that and route orbit/pan/zoom/pick to the camera.
pub fn cursor_over_viewport(&self, cursor: (f32, f32)) -> bool {
let Some((x0, y0, x1, y1)) = self.viewport_rect_px else {
return false;
};
cursor.0 >= x0 && cursor.0 <= x1 && cursor.1 >= y0 && cursor.1 <= y1
}
/// The Viewport tab's most-recent rect in physical pixels, as a
/// [`Rect`](oxide_engine::math::Rect). The host's render path uses this
/// to restrict the wgpu viewport and projection so the 3D scene
/// matches the tab's bounds (rather than stretching across the whole
/// window) and so picking aligns with what the user sees. `None` until
/// the first UI build (the editor's render falls back to the full
/// surface in that case).
pub fn viewport_rect(&self) -> Option<oxide_engine::math::Rect> {
let (x0, y0, x1, y1) = self.viewport_rect_px?;
Some(oxide_engine::math::Rect::from_min_size(
oxide_engine::math::Vec2::new(x0, y0),
oxide_engine::math::Vec2::new((x1 - x0).max(1.0), (y1 - y0).max(1.0)),
))
}
/// The shipped layout: Hierarchy on the left, Inspector on the right,
/// Viewport + UI Canvas in the center (both need the large central area),
/// Project / Console along the bottom.
fn default_dock() -> DockState<PanelKind> {
// The UI Canvas shares the central node with the Viewport as tabs: it
// needs both horizontal and vertical room, so the thin bottom strip is
// the wrong home for it.
let mut dock = DockState::new(vec![PanelKind::Viewport, PanelKind::UiCanvas]);
let root = NodeIndex::root();
let surface = dock.main_surface_mut();
let [_center, _left] = surface.split_left(root, 0.22, vec![PanelKind::Hierarchy]);
let [center_remaining, _right] =
surface.split_right(NodeIndex::root(), 0.78, vec![PanelKind::Inspector]);
// Bottom strip on the center column carries Project + Console as tabs.
surface.split_below(
center_remaining,
0.70,
vec![PanelKind::Project, PanelKind::Console, PanelKind::Terminal],
);
dock
}
// ---- project / watcher -------------------------------------------
/// Opens `path` as a project, clearing the undo history and starting a
/// file watcher over the project's `assets/`, `scenes/`, and `scripts/`
/// directories so live-reload is on by default.
pub fn open_project(&mut self, path: impl Into<PathBuf>) -> Result<(), ProjectError> {
let path = path.into();
let project = Project::open(&path)?;
self.state.recent.record(project.root());
self.attach_watcher(&project);
self.open_asset_db(project.root());
self.state.project = Some(project);
self.commands.clear();
self.status.say(format!("Opened {}", path.display()));
Ok(())
}
/// Creates a new project at `path` with the given display name.
pub fn create_project(
&mut self,
path: impl Into<PathBuf>,
name: impl Into<String>,
) -> Result<(), ProjectError> {
let path = path.into();
let project = Project::create(&path, name)?;
self.state.recent.record(project.root());
// Seed the bundled default UI font so a fresh project has something to
// pick in the asset browser, referenced by project-relative path.
match crate::assets::seed_default_font(&project.assets_dir()) {
Ok(true) => self.status.say("Added default UI font"),
Ok(false) => {}
Err(err) => log::warn!("could not seed default font: {err}"),
}
self.attach_watcher(&project);
self.open_asset_db(project.root());
self.state.project = Some(project);
self.commands.clear();
self.status.say(format!("Created {}", path.display()));
Ok(())
}
/// Closes the open project (if any), stopping the watcher.
pub fn close_project(&mut self) {
self.state.project = None;
self.state.asset_db = None;
self.watcher = None;
self.watcher_events = None;
self.commands.clear();
self.status.say("Closed project");
}
/// Opens (and scans) the asset database for the project at `root`, then
/// persists the manifest so freshly-assigned uids survive the next session.
/// Stored on [`EditorState::asset_db`] for the browser and asset-picker.
fn open_asset_db(&mut self, root: &std::path::Path) {
let mut db = AssetDatabase::open(root);
db.scan();
if let Err(err) = db.save() {
log::warn!("could not write asset manifest: {err}");
}
self.state.asset_db = Some(db);
}
fn attach_watcher(&mut self, project: &Project) {
// ~150 ms is the Stage-6 default; long enough to coalesce a "save"
// burst, short enough that the editor feels responsive.
match FileWatcher::new(Duration::from_millis(150)) {
Ok((mut watcher, events)) => {
let mut failed = false;
for dir in [
project.assets_dir(),
project.scenes_dir(),
project.scripts_dir(),
] {
if dir.exists() {
if let Err(err) = watcher.watch(&dir) {
log::warn!("file watcher could not watch {}: {err}", dir.display());
failed = true;
}
}
}
self.watcher = Some(watcher);
self.watcher_events = Some(events);
if failed {
self.status.say("File watcher partially active (see log)");
}
}
Err(err) => {
log::warn!("file watcher unavailable: {err}");
self.watcher = None;
self.watcher_events = None;
self.status.say("File watcher unavailable");
}
}
}
/// Called once per frame (between event handling and rendering) to drain
/// any settled file-watcher events into the asset server. Cheap when
/// there's nothing to do.
pub fn frame_tick(&mut self) {
if let Some(rx) = &self.watcher_events {
let mut events: Vec<ChangeEvent> = Vec::new();
while let Ok(ev) = rx.try_recv() {
events.push(ev);
}
if !events.is_empty() {
let n = reload_changed_assets(&self.state.assets, events);
if n > 0 {
self.status.say(format!("Reloaded {n} asset(s)"));
}
// A watcher burst may have added or removed files under
// `assets/`; reconcile the database so the browser and picker
// reflect the change. Cheap (a directory walk) and only when
// something actually changed on disk.
if let Some(db) = &mut self.state.asset_db {
let added = db.scan();
if added > 0 {
let _ = db.save();
self.status.say(format!("Imported {added} asset(s)"));
}
}
}
}
if self.status.ttl > 0 {
self.status.ttl -= 1;
}
}
// ---- key bindings -------------------------------------------------
/// Returns whether `key_event` was handled (so the caller can suppress
/// further processing). Handles editor-global shortcuts:
///
/// - `Ctrl+Z` — undo
/// - `Ctrl+Y` / `Ctrl+Shift+Z` — redo
/// - `Ctrl+S` — save project (no-op without a project)
/// - `Ctrl+,` — toggle Preferences
/// - `Ctrl+P` — Play / Pause toggle (Play when editing; Pause⇄Resume when
/// running)
/// - `Ctrl+.` — Step one fixed tick (while paused)
pub fn try_consume_shortcut(&mut self, ctrl: bool, shift: bool, ch: Option<char>) -> bool {
if !ctrl {
return false;
}
let Some(ch) = ch.map(|c| c.to_ascii_lowercase()) else {
return false;
};
match ch {
'z' if !shift => {
if let Some(label) = self.commands.undo(&mut self.state) {
self.status.say(format!("Undo: {label}"));
}
true
}
'y' | 'z' /* shift+z = redo */ => {
if let Some(label) = self.commands.redo(&mut self.state) {
self.status.say(format!("Redo: {label}"));
}
true
}
's' => {
self.save_project();
true
}
',' => {
self.show_preferences = !self.show_preferences;
true
}
'p' => {
// Play when editing; toggle pause while running.
if self.state.is_in_play() {
self.toggle_pause();
} else {
self.play();
}
true
}
'.' => {
self.request_step();
true
}
_ => false,
}
}
/// Sets (or clears) the selection from outside the UI pass — used by the
/// viewport's ray-pick on click. Also refreshes the inspector's rename
/// buffer and the rotation-euler scratch buffer, so the inspector reflects
/// the new selection on its next frame.
pub fn select(&mut self, entity: Option<Entity>) {
self.state.selected = entity;
self.rename_buf = entity
.and_then(|e| self.state.scene.name(e))
.unwrap_or_default();
self.euler_for = None;
}
fn save_project(&mut self) {
let Some(project) = &self.state.project else {
self.status.say("No project open");
return;
};
match project.save() {
Ok(()) => self.status.say(format!("Saved {}", project.name())),
Err(err) => self.status.say(format!("Save failed: {err}")),
}
}
// ---- top-level UI build ------------------------------------------
/// Builds the editor UI for one frame: menu bar, dock area, status bar,
/// and any open modal windows. Must be idempotent — egui may run the
/// 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);
self.status_bar(ui);
self.preferences_window(ui);
self.about_window(ui);
self.layer_editor_window(ui);
self.group_editor_window(ui);
self.new_project_window(ui);
self.open_project_window(ui);
// Clear the rect each frame so a hidden Viewport tab reverts to
// "cursor never over viewport" — the next time the tab is shown its
// `ui` callback refills it.
self.viewport_rect_px = None;
let pixels_per_point = ui.ctx().pixels_per_point();
egui::CentralPanel::default()
.frame(egui::Frame::NONE)
.show_inside(ui, |ui| {
let mut viewer = ShellTabViewer {
state: &mut self.state,
commands: &mut self.commands,
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,
show_layer_editor: &mut self.show_layer_editor,
show_group_editor: &mut self.show_group_editor,
viewport_rect_px: &mut self.viewport_rect_px,
viewport_overlay: self.viewport_overlay,
pixels_per_point,
show_colliders: self.show_colliders,
raycast_probe_viz: self.raycast_probe_viz,
terminals: &mut self.terminals,
active_terminal: &mut self.active_terminal,
};
DockArea::new(&mut self.dock)
.style(Style::from_egui(ui.style().as_ref()))
.show_inside(ui, &mut viewer);
});
// Apply the structural edits collected during the UI pass.
let actions = std::mem::take(&mut self.pending);
for action in actions {
self.apply(action);
}
}
fn menu_bar(&mut self, ui: &mut egui::Ui) {
egui::Panel::top("oxide.menu_bar").show_inside(ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("New Project…").clicked() {
// Pre-fill with a sensible default so the user only
// has to confirm; they can edit either field.
if self.new_project_path.is_empty() {
if let Some(home) = std::env::var_os("HOME") {
let mut p = PathBuf::from(home);
p.push("oxide-projects/untitled");
self.new_project_path = p.display().to_string();
}
}
if self.new_project_name.is_empty() {
self.new_project_name = "Untitled".to_owned();
}
self.show_new_project = true;
ui.close();
}
if ui.button("Open Project…").clicked() {
if self.open_project_path.is_empty() {
// Suggest the last-used project as a starting
// point if there is one.
if let Some(p) = self.state.recent.entries().last() {
self.open_project_path = p.display().to_string();
}
}
self.show_open_project = true;
ui.close();
}
ui.menu_button("Open Recent", |ui| {
if self.state.recent.entries().is_empty() {
ui.weak("(none)");
}
let recents: Vec<PathBuf> = self.state.recent.entries().to_vec();
for path in recents {
if ui.button(path.display().to_string()).clicked() {
if let Err(err) = self.open_project(&path) {
self.status.say(format!("Open failed: {err}"));
}
ui.close();
}
}
});
ui.separator();
let enabled = self.state.project.is_some();
if ui
.add_enabled(enabled, egui::Button::new("Save Project"))
.clicked()
{
self.save_project();
ui.close();
}
if ui
.add_enabled(enabled, egui::Button::new("Close Project"))
.clicked()
{
self.close_project();
ui.close();
}
ui.separator();
if ui.button("Quit (Ctrl+Q)").clicked() {
// Flagged here, observed by the host runner in
// `update()` next frame — egui's `ViewportCommand::Close`
// is a viewport-level signal, not a winit close, so
// we route through our own mechanism.
self.quit_requested = true;
ui.close();
}
});
ui.menu_button("Edit", |ui| {
// Keep the menu labels short — long action names made the
// buttons stretch vertically. The label still appears in
// the status bar after the action runs.
let can_undo = self.commands.can_undo();
let can_redo = self.commands.can_redo();
if ui
.add_enabled(can_undo, egui::Button::new("Undo"))
.clicked()
{
if let Some(label) = self.commands.undo(&mut self.state) {
self.status.say(format!("Undo: {label}"));
}
ui.close();
}
if ui
.add_enabled(can_redo, egui::Button::new("Redo"))
.clicked()
{
if let Some(label) = self.commands.redo(&mut self.state) {
self.status.say(format!("Redo: {label}"));
}
ui.close();
}
});
ui.menu_button("View", |ui| {
for kind in [
PanelKind::Hierarchy,
PanelKind::Inspector,
PanelKind::Viewport,
PanelKind::Project,
PanelKind::UiCanvas,
PanelKind::Console,
] {
let visible = self.dock_contains(&kind);
if ui
.selectable_label(visible, format!("Show {}", kind.title()))
.clicked()
{
self.toggle_panel(kind);
ui.close();
}
}
ui.separator();
// Viewport overlays (Stage 9 piece 8b: physics collider wireframes).
if ui
.selectable_label(self.show_colliders, "Show Colliders")
.clicked()
{
self.show_colliders = !self.show_colliders;
ui.close();
}
// Stage 9 piece 8c: raycast debug probe (click to freeze a
// camera→cursor ray into the world, then orbit to view it).
if ui
.selectable_label(self.raycast_probe, "Raycast Probe")
.clicked()
{
self.raycast_probe = !self.raycast_probe;
if self.raycast_probe {
self.set_status_hint(
"Raycast probe on — click in the viewport to cast a debug ray",
);
} else {
self.raycast_probe_viz = None;
}
ui.close();
}
});
ui.menu_button("Project", |ui| {
if let Some(project) = &self.state.project {
ui.label(format!("Open: {}", project.name()));
ui.label(project.root().display().to_string());
} else {
ui.weak("(no project open)");
}
});
// Module-contributed menu items, attributed to their owning
// module's group; built-in menu items above are not routed
// through the registry.
if self.extensions.iter_menu_items().next().is_some() {
ui.menu_button("Modules", |ui| {
let paths: Vec<String> = self
.extensions
.iter_menu_items()
.map(|i| i.path.clone())
.collect();
for path in paths {
if ui.button(&path).clicked() {
self.invoke_menu_item(&path);
ui.close();
}
}
});
}
ui.menu_button("Help", |ui| {
if ui.button("About Oxide…").clicked() {
self.show_about = true;
ui.close();
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("⚙ Preferences").clicked() {
self.show_preferences = !self.show_preferences;
}
});
});
});
}
/// The play-mode toolbar (Stage 8.7): a centered Play/Pause/Step/Stop row
/// just below the menu bar. Buttons are gated by the current
/// [`PlayState`](crate::state::PlayState) — Pause/Step/Stop only enable while
/// running — and a "PLAYING"/"PAUSED" badge makes edit-vs-play unambiguous.
/// Text labels (no media glyphs) keep it readable in egui's bundled font.
fn play_toolbar(&mut self, ui: &mut egui::Ui) {
egui::Panel::top("oxide.play_toolbar").show_inside(ui, |ui| {
ui.horizontal(|ui| {
let playing = self.state.play == PlayState::Playing;
let paused = self.state.play == PlayState::Paused;
let in_play = self.state.is_in_play();
// Play / Resume — disabled while already playing. While paused
// this resumes (un-pauses); while editing it starts play.
let play_label = if paused { "Resume" } else { "Play" };
if ui
.add_enabled(!playing, egui::Button::new(play_label))
.on_hover_text("Run the scene (Ctrl+P)")
.clicked()
{
self.play_or_resume();
}
// Pause — only while playing.
if ui
.add_enabled(playing, egui::Button::new("Pause"))
.on_hover_text("Freeze the simulation (Ctrl+P)")
.clicked()
{
self.toggle_pause();
}
// Step — one fixed tick, only while paused.
if ui
.add_enabled(paused, egui::Button::new("Step"))
.on_hover_text("Advance one fixed tick (Ctrl+.)")
.clicked()
{
self.request_step();
}
// Stop — restore the scene, only while running.
if ui
.add_enabled(in_play, egui::Button::new("Stop"))
.on_hover_text("Stop and restore the scene")
.clicked()
{
self.stop();
}
// State badge so edit-vs-play is never ambiguous.
ui.separator();
if playing {
ui.colored_label(egui::Color32::from_rgb(120, 220, 120), "PLAYING");
} else if paused {
ui.colored_label(egui::Color32::from_rgb(235, 200, 90), "PAUSED");
} else {
ui.weak("Editing");
}
});
});
}
fn status_bar(&mut self, ui: &mut egui::Ui) {
egui::Panel::bottom("oxide.status_bar").show_inside(ui, |ui| {
ui.horizontal(|ui| {
if self.status.ttl > 0 {
ui.label(&self.status.text);
} else {
ui.weak("ready");
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let depth = self.commands.undo_depth();
ui.weak(format!("undo: {depth}"));
ui.separator();
let modules = self.extensions.modules().count();
ui.weak(format!("modules: {modules}"));
});
});
});
}
fn preferences_window(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let mut open = self.show_preferences;
egui::Window::new("Preferences")
.open(&mut open)
.default_size([640.0, 480.0])
.resizable(true)
.show(&ctx, |ui| {
egui::Panel::left("oxide.prefs.sidebar")
.default_size(160.0)
.show_inside(ui, |ui| {
ui.heading("Sections");
for name in self.state.settings.names() {
ui.label(name);
}
ui.separator();
ui.heading("Modules");
let modules: Vec<&'static str> = self.extensions.modules().collect();
for m in modules {
let mut enabled = self.extensions.is_module_enabled(m);
if ui.checkbox(&mut enabled, m).changed() {
self.extensions.set_module_enabled(m, enabled);
}
}
});
egui::CentralPanel::default().show_inside(ui, |ui| {
ui.heading("Settings");
// Snapshot the section names so the central panel can
// mutably borrow `self` (the bindings UI needs to call
// `self.input_bindings_page` and edit `self.state`).
let section_names: Vec<&'static str> = self.state.settings.names().collect();
if section_names.is_empty() {
ui.weak(
"No settings sections registered yet. Engine, editor, and \
modules add sections here.",
);
}
for name in section_names {
// The input bindings section gets a rich UI; every
// other section still shows raw RON until a per-
// 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 {
Some(ron) => {
ui.monospace(ron);
}
None => {
ui.weak("(no value)");
}
});
}
}
ui.separator();
ui.weak(
"Rich per-section editors arrive incrementally — engine prefs \
(Stage 7+ render quality), editor prefs (theme, layout), and \
per-module pages from the extension API.",
);
});
});
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
/// "Restore all defaults" button. While a capture is in flight the
/// targeted slot shows "Press a key… (Esc to cancel)" and the rest of
/// the page is read-only.
fn input_bindings_page(&mut self, ui: &mut egui::Ui) {
let capturing = self.capture.is_some();
ui.horizontal(|ui| {
ui.label(if capturing {
"Press a key or button to bind · Esc cancels"
} else {
"Click a binding to change it · empty slot adds"
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Restore all defaults").clicked() {
self.pending.push(PendingAction::RestoreAllBindings);
}
});
});
ui.separator();
// Buttons -----------------------------------------------------------
let button_actions: Vec<String> =
self.state.actions.actions().map(str::to_string).collect();
if !button_actions.is_empty() {
ui.heading("Buttons");
for action in &button_actions {
self.button_row(ui, action, capturing);
}
ui.add_space(8.0);
}
// 1D axes ----------------------------------------------------------
let axis_actions: Vec<String> = self.state.actions.axes().map(str::to_string).collect();
if !axis_actions.is_empty() {
ui.heading("Axes (1D)");
for action in &axis_actions {
self.axis_row(ui, action, capturing);
}
ui.add_space(8.0);
}
// 2D axes ----------------------------------------------------------
let axis_2d_actions: Vec<String> =
self.state.actions.axes_2d().map(str::to_string).collect();
if !axis_2d_actions.is_empty() {
ui.heading("Axes (2D)");
for action in &axis_2d_actions {
self.axis_2d_row(ui, action, capturing);
}
}
}
fn button_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) {
let bindings = self.state.actions.bindings(action).to_vec();
ui.horizontal(|ui| {
ui.label(action);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("↺").on_hover_text("Restore defaults").clicked() {
self.pending
.push(PendingAction::RestoreActionDefaults(action.to_string()));
}
if ui.button("+").on_hover_text("Add binding").clicked() && !capturing {
self.capture = Some(CaptureTarget::Button {
action: action.to_string(),
slot: BindingSlot::Append,
});
}
for (i, binding) in bindings.iter().enumerate().rev() {
if ui.small_button("🗑").on_hover_text("Remove").clicked() {
self.pending.push(PendingAction::RemoveButtonBinding {
action: action.to_string(),
index: i,
});
}
let label = self.slot_label(
&CaptureTarget::Button {
action: action.to_string(),
slot: BindingSlot::Replace(i),
},
Some(*binding),
);
if ui.button(label).clicked() && !capturing {
self.capture = Some(CaptureTarget::Button {
action: action.to_string(),
slot: BindingSlot::Replace(i),
});
}
}
});
});
}
fn axis_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) {
let axis = self.state.actions.axis_bindings(action).cloned();
let Some(axis) = axis else {
return;
};
ui.horizontal(|ui| {
ui.label(action);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("↺").on_hover_text("Restore defaults").clicked() {
self.pending
.push(PendingAction::RestoreActionDefaults(action.to_string()));
}
});
});
self.direction_row(
ui,
action,
AxisDirection::Axis(AxisSide::Positive),
&axis.positive,
capturing,
);
self.direction_row(
ui,
action,
AxisDirection::Axis(AxisSide::Negative),
&axis.negative,
capturing,
);
}
fn axis_2d_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) {
let axis = self.state.actions.axis_2d_bindings(action).cloned();
let Some(axis) = axis else {
return;
};
ui.horizontal(|ui| {
ui.label(action);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("↺").on_hover_text("Restore defaults").clicked() {
self.pending
.push(PendingAction::RestoreActionDefaults(action.to_string()));
}
});
});
self.direction_row(
ui,
action,
AxisDirection::Axis2D(Axis2DSide::Right),
&axis.x.positive,
capturing,
);
self.direction_row(
ui,
action,
AxisDirection::Axis2D(Axis2DSide::Left),
&axis.x.negative,
capturing,
);
self.direction_row(
ui,
action,
AxisDirection::Axis2D(Axis2DSide::Up),
&axis.y.positive,
capturing,
);
self.direction_row(
ui,
action,
AxisDirection::Axis2D(Axis2DSide::Down),
&axis.y.negative,
capturing,
);
}
fn direction_row(
&mut self,
ui: &mut egui::Ui,
action: &str,
dir: AxisDirection,
bindings: &[Binding],
capturing: bool,
) {
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.weak(dir.label());
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("+").on_hover_text("Add binding").clicked() && !capturing {
self.capture = Some(make_axis_capture(action, dir, BindingSlot::Append));
}
for (i, binding) in bindings.iter().enumerate().rev() {
if ui.small_button("🗑").on_hover_text("Remove").clicked() {
self.pending.push(PendingAction::RemoveAxisBinding {
action: action.to_string(),
dir,
index: i,
});
}
let target = make_axis_capture(action, dir, BindingSlot::Replace(i));
let label = self.slot_label(&target, Some(*binding));
if ui.button(label).clicked() && !capturing {
self.capture = Some(target);
}
}
});
});
}
/// The label shown on a binding button. Becomes "Press a key…" when the
/// slot is the active capture target; otherwise the binding's own
/// description (or "(empty)" for an Append slot with no value yet).
fn slot_label(&self, target: &CaptureTarget, binding: Option<Binding>) -> String {
if Some(target) == self.capture.as_ref() {
return "Press a key…".to_string();
}
match binding {
Some(b) => describe_binding(&b),
None => "(empty)".to_string(),
}
}
fn about_window(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let mut open = self.show_about;
egui::Window::new("About Oxide")
.open(&mut open)
.resizable(false)
.show(&ctx, |ui| {
ui.heading("Oxide Engine");
ui.label("In-engine editor — Stage 6 shell.");
ui.label(format!("Engine v{}", env!("CARGO_PKG_VERSION")));
});
self.show_about = open;
}
/// Project-wide layer-name editor. Lists the 32 layer slots; each one has
/// an editable name + a Clear button. Index 0 is `"Default"` by default
/// and can be renamed but the layer itself can't be removed.
fn layer_editor_window(&mut self, ui: &mut egui::Ui) {
use oxide_engine::layer::MAX_LAYERS;
let ctx = ui.ctx().clone();
let mut open = self.show_layer_editor;
// Pull all current names into a parallel String buffer for editing,
// then write back any that changed when the user edits a row.
let mut edits: Vec<(u32, Option<String>)> = Vec::new();
egui::Window::new("Layer Names")
.open(&mut open)
.default_size([340.0, 560.0])
.resizable(true)
.show(&ctx, |ui| {
ui.label(
"Project-wide layer names. Cameras, raycasts, and physics filters use \
these to refer to layers by name instead of bare bit indices.",
);
ui.label(
egui::RichText::new("Edit a name and press Enter or click away to apply.")
.weak(),
);
ui.separator();
// Fill the window width (don't shrink to content) so the name
// fields can stretch the full row.
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
for i in 0..MAX_LAYERS {
let current = self
.state
.layer_registry
.name(i)
.map(String::from)
.unwrap_or_default();
let mut buf = current.clone();
ui.horizontal(|ui| {
ui.monospace(format!("{i:>2}"));
// Reserve room for the trailing clear button so
// the field fills the rest of the row and grows
// when the window is widened.
let field_w = (ui.available_width() - 30.0).max(80.0);
let edited = ui.add(
egui::TextEdit::singleline(&mut buf)
.desired_width(field_w)
.hint_text("(unnamed)"),
);
// Commit on focus loss (Enter or click-away).
if edited.lost_focus() && buf != current {
edits.push((
i,
if buf.trim().is_empty() {
None
} else {
Some(buf.trim().to_string())
},
));
}
if !current.is_empty()
&& ui
.small_button("🗑")
.on_hover_text("Clear this layer's name")
.clicked()
{
edits.push((i, None));
}
});
}
});
});
for (i, new_name) in edits {
match new_name {
Some(name) => self.state.layer_registry.set(i, name),
None => self.state.layer_registry.clear(i),
}
}
self.show_layer_editor = open;
}
/// Project-wide gameplay-group editor. Lists the defined groups (each with a
/// delete button) and an "add group" row. Unlike layers (a fixed 32-slot
/// bitset) groups are an open-ended named set, so this grows/shrinks freely.
/// Deleting a group only removes it from the project's vocabulary — entities
/// already tagged with it keep the tag (shown as an "ungrouped tag" in the
/// inspector) until cleared there.
fn group_editor_window(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let mut open = self.show_group_editor;
let mut to_remove: Option<String> = None;
let mut to_add: Option<String> = None;
egui::Window::new("Groups")
.open(&mut open)
.default_size([320.0, 420.0])
.resizable(true)
.show(&ctx, |ui| {
ui.label(
"Project-wide gameplay groups. Tag nodes with any of these in the \
inspector; game code and scripts query membership by name.",
);
ui.separator();
egui::ScrollArea::vertical()
.max_height(320.0)
.show(ui, |ui| {
let defined: Vec<String> =
self.state.group_registry.iter().map(String::from).collect();
if defined.is_empty() {
ui.weak("No groups defined yet. Add one below.");
}
for name in defined {
ui.horizontal(|ui| {
if ui
.small_button("🗑")
.on_hover_text("Delete this group")
.clicked()
{
to_remove = Some(name.clone());
}
ui.label(&name);
});
}
});
ui.separator();
ui.horizontal(|ui| {
let resp = ui.text_edit_singleline(&mut self.new_group_name);
let entered =
resp.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
let add_clicked = ui.button("Add").clicked();
if (add_clicked || entered) && !self.new_group_name.trim().is_empty() {
to_add = Some(self.new_group_name.trim().to_string());
}
});
});
if let Some(name) = to_add {
self.state.group_registry.define(name);
self.new_group_name.clear();
}
if let Some(name) = to_remove {
self.state.group_registry.undefine(&name);
}
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).
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);
ui.horizontal(|ui| {
let valid = !self.new_project_path.trim().is_empty()
&& !self.new_project_name.trim().is_empty();
if ui.add_enabled(valid, egui::Button::new("Create")).clicked() {
create_now = true;
}
if ui.button("Cancel").clicked() {
cancel_now = true;
}
});
ui.weak(
"Creates the folder layout (assets/, scenes/, scripts/) and writes \
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();
match self.create_project(path, name) {
Ok(()) => {
self.show_new_project = false;
}
Err(err) => {
self.status.say(format!("Create failed: {err}"));
// Leave the dialog open so the user can fix the path.
}
}
} else if cancel_now {
self.show_new_project = false;
} else {
self.show_new_project = open;
}
}
/// In-app Open Project dialog: a path field with a native Browse… picker,
/// 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();
if ui.add_enabled(valid, egui::Button::new("Open")).clicked() {
open_now = true;
}
if ui.button("Cancel").clicked() {
cancel_now = true;
}
});
});
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) {
Ok(()) => {
self.show_open_project = false;
}
Err(err) => {
self.status.say(format!("Open failed: {err}"));
}
}
} else if cancel_now {
self.show_open_project = false;
} else {
self.show_open_project = open;
}
}
fn dock_contains(&self, kind: &PanelKind) -> bool {
self.dock.iter_all_tabs().any(|(_, tab)| tab == kind)
}
fn toggle_panel(&mut self, kind: PanelKind) {
// Existing tab: remove it. Missing tab: drop it into the currently-
// focused dock leaf so the user gets it back somewhere visible.
let path = self
.dock
.iter_all_tabs()
.find_map(|(path, tab)| if *tab == kind { Some(path) } else { None });
match path {
Some(path) => {
self.dock.remove_tab(path);
}
None => {
self.dock.push_to_focused_leaf(kind);
}
}
}
fn invoke_menu_item(&mut self, path: &str) {
let mut fired = false;
for item in self.extensions.iter_menu_items_mut() {
if item.path == path {
(item.action)();
fired = true;
break;
}
}
if fired {
self.status.say(format!("Menu: {path}"));
}
}
/// Selects a freshly spawned entity and syncs the inspector scratch buffers
/// (rename field + euler cache) to it.
fn select_spawned(&mut self, entity: Entity) {
self.rename_buf = self.state.scene.name(entity).unwrap_or_default();
self.state.selected = Some(entity);
self.euler_for = None;
}
fn apply(&mut self, action: PendingAction) {
match action {
PendingAction::AddRootPrefab(name) => {
// Disjoint field borrows: prefab_registry (read) + scene (write)
// + registry (read) are separate fields of `state`.
if let Some(e) = self.state.prefab_registry.spawn(
&name,
&mut self.state.scene,
&self.state.registry,
) {
self.select_spawned(e);
}
}
PendingAction::AddChildPrefab(parent, name) => {
if self.state.scene.contains(parent) {
if let Some(e) = self.state.prefab_registry.spawn_child(
&name,
parent,
&mut self.state.scene,
&self.state.registry,
) {
self.select_spawned(e);
}
}
}
PendingAction::Delete(entity) => {
self.state.scene.despawn(entity, DespawnPolicy::Recursive);
self.state.component_order.remove(&entity);
if self.state.selected == Some(entity) {
self.state.selected = None;
}
}
PendingAction::Duplicate(entity) => {
if self.state.scene.contains(entity) {
let name = self.state.scene.name(entity).unwrap_or_default();
let transform = self.state.scene.local_transform(entity).unwrap_or_default();
let parent = self.state.scene.parent(entity);
let copy_name = format!("{name} copy");
let new = match parent {
Some(p) => self
.state
.scene
.spawn_child(p, copy_name.clone(), transform),
None => self.state.scene.spawn(copy_name.clone(), transform),
};
// Copy every registered modular component via whole-value
// RON round-trip. Essential (node-baked) components are
// already set by spawn — but Layer' mask isn't, so copy
// that too separately.
let comps: Vec<&'static str> = self
.state
.registry
.components_on(self.state.scene.world(), entity)
.into_iter()
.filter(|n| !is_essential_component(n) || *n == "Layer")
.collect();
for comp in comps {
if let Ok(ron) =
self.state
.registry
.get_ron(self.state.scene.world(), entity, comp)
{
let _ = self.state.registry.set_ron(
self.state.scene.world_mut(),
new,
comp,
&ron,
);
}
}
// Preserve the inspector order from the source so the copy
// shows its components in the same arrangement.
if let Some(order) = self.state.component_order.get(&entity).cloned() {
self.state.component_order.insert(new, order);
}
// Carry the per-component disable set along too —
// DisabledComponents isn't in the reflection registry, so
// the registry-driven copy above doesn't see it. Scope the
// immutable borrow so the world is freed before insert_one.
let dc: Option<DisabledComponents> = {
let world = self.state.scene.world();
world
.get::<&DisabledComponents>(entity)
.ok()
.map(|d| (*d).clone())
};
if let Some(dc) = dc {
let _ = self.state.scene.world_mut().insert_one(new, dc);
}
self.state.selected = Some(new);
self.rename_buf = copy_name;
self.euler_for = None;
}
}
PendingAction::SetEnabled(entity, enabled) => {
self.state.scene.set_enabled(entity, enabled);
}
PendingAction::RestoreAllBindings => {
self.restore_default_bindings();
}
PendingAction::RestoreActionDefaults(action) => {
self.state.actions.restore_defaults(&action);
self.state.actions.restore_axis_defaults(&action);
self.state.actions.restore_axis_2d_defaults(&action);
self.state.sync_action_overrides_to_settings();
self.bindings_dirty = true;
self.status.say(format!("Restored '{action}' defaults"));
}
PendingAction::RemoveButtonBinding { action, index } => {
let mut bindings = self.state.actions.bindings(&action).to_vec();
if index < bindings.len() {
bindings.remove(index);
self.state.actions.set_bindings(&action, bindings);
self.state.sync_action_overrides_to_settings();
self.bindings_dirty = true;
}
}
PendingAction::RemoveAxisBinding { action, dir, index } => match dir {
AxisDirection::Axis(side) => {
if let Some(current) = self.state.actions.axis_bindings(&action).cloned() {
let mut next = current;
let list = side_list_mut(&mut next, side);
if index < list.len() {
list.remove(index);
self.state.actions.set_axis_bindings(&action, next);
self.state.sync_action_overrides_to_settings();
self.bindings_dirty = true;
}
}
}
AxisDirection::Axis2D(side) => {
if let Some(current) = self.state.actions.axis_2d_bindings(&action).cloned() {
let mut next = current;
let list = side_2d_list_mut(&mut next, side);
if index < list.len() {
list.remove(index);
self.state.actions.set_axis_2d_bindings(&action, next);
self.state.sync_action_overrides_to_settings();
self.bindings_dirty = true;
}
}
}
},
}
}
}
impl Default for Shell {
fn default() -> Self {
Self::new()
}
}
// ---- tab viewer (renders each panel's content) -------------------------
/// Holds the mutable references each panel needs to render. Constructed
/// per-frame inside [`Shell::build`] so the borrow lives only for one
/// `DockArea::show` call.
struct ShellTabViewer<'a> {
state: &'a mut EditorState,
commands: &'a mut CommandStack<EditorState>,
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,
euler_for: &'a mut Option<(Entity, &'static str, &'static str)>,
/// Set to `true` when the Layer dropdown's "Edit names…" entry is clicked.
show_layer_editor: &'a mut bool,
/// Set to `true` when the Groups dropdown's "Edit groups…" entry is clicked.
show_group_editor: &'a mut bool,
/// Filled when the Viewport tab renders, so the shell can answer
/// `cursor_over_viewport` next frame.
viewport_rect_px: &'a mut Option<(f32, f32, f32, f32)>,
/// Per-frame projection data the Viewport tab's gizmo overlay paints
/// with (set by the host before `Shell::build`). `None` skips the
/// overlay this frame.
viewport_overlay: Option<ViewportOverlay>,
/// Physical-pixel scale for converting egui logical coords (points) to
/// the pixel-space cursor coordinates the host uses.
pixels_per_point: f32,
/// When set, the Viewport tab paints collider wireframe gizmos (View ▸
/// Show Colliders). Copied in from [`Shell::show_colliders`].
show_colliders: bool,
/// The raycast-probe result to paint this frame, if any (View ▸ Raycast
/// Probe). Copied in from [`Shell::raycast_probe_viz`].
raycast_probe_viz: Option<RaycastProbeViz>,
/// The interactive PTY terminal sessions shown as tabs (the Terminal panel
/// renders/drives them). Borrowed from [`Shell::terminals`].
terminals: &'a mut Vec<crate::pty::PtyTerminal>,
/// The active terminal tab index. Borrowed from [`Shell::active_terminal`].
active_terminal: &'a mut usize,
}
impl<'a> egui_dock::TabViewer for ShellTabViewer<'a> {
type Tab = PanelKind;
fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
tab.title().into()
}
fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
match tab {
PanelKind::Hierarchy => self.hierarchy(ui),
PanelKind::Inspector => self.inspector(ui),
// The 3D viewport is rendered to the full surface *before* egui
// paints, so the Viewport tab is left empty and its background is
// suppressed (see `clear_background` below) — the 3D content
// shows through where the Viewport tab is, and the opaque
// backgrounds of every other panel occlude the rest. The rect
// is captured so the host knows where the cursor is interacting
// with the 3D scene rather than an opaque panel.
PanelKind::Viewport => {
let r = ui.max_rect();
let s = self.pixels_per_point;
*self.viewport_rect_px = Some((r.min.x * s, r.min.y * s, r.max.x * s, r.max.y * s));
if self.show_colliders {
self.paint_collider_gizmos(ui, r);
}
self.paint_raycast_probe(ui, r);
self.paint_gizmo_overlay(ui, r);
self.paint_play_indicator(ui, r);
}
PanelKind::Project => self.project_panel(ui),
PanelKind::UiCanvas => self.ui_canvas_panel(ui),
PanelKind::Console => self.console(ui),
PanelKind::Terminal => self.terminal_panel(ui),
PanelKind::Custom(name) => self.custom_panel(ui, name),
}
}
fn clear_background(&self, tab: &Self::Tab) -> bool {
// Transparent only for the 3D viewport tab; every other tab keeps its
// background or it'd bleed onto the 3D scene behind.
!matches!(tab, PanelKind::Viewport)
}
}
impl<'a> ShellTabViewer<'a> {
// --- Gizmo overlay rendering (Stage 7 piece 6c) -----------------------
/// Paints the active gizmo's handles over the Viewport tab using egui's
/// 2D painter — same convention every DCC tool uses for transform
/// gizmos. Hit-testing is in 3D world space (see `crate::gizmo`); this
/// is purely the visualization.
/// Paints a play-state border + corner badge over the viewport so it is
/// unmistakable when the scene is running vs. being authored — the editor's
/// analogue of Unity's play-mode tint. No-op while editing.
fn paint_play_indicator(&self, ui: &egui::Ui, tab_rect: egui::Rect) {
let (color, label) = match self.state.play {
PlayState::Playing => (egui::Color32::from_rgb(120, 220, 120), "PLAYING"),
PlayState::Paused => (egui::Color32::from_rgb(235, 200, 90), "PAUSED"),
PlayState::Editing => return,
};
let painter = ui.painter_at(tab_rect);
// Inset by half the stroke width so the full border stays inside the
// viewport rect rather than being clipped at the edges.
stroke_rect(
&painter,
tab_rect.shrink(1.5),
egui::Stroke::new(3.0_f32, color),
);
painter.text(
tab_rect.left_top() + egui::vec2(8.0, 6.0),
egui::Align2::LEFT_TOP,
label,
egui::FontId::proportional(14.0),
color,
);
}
/// Paints a wireframe outline of every entity's physics
/// [`Collider`](oxide_physics::Collider) over the Viewport (Stage 9 piece
/// 8b), projected with the same view-projection the scene was drawn with.
/// Solid colliders are green, sensor (trigger) colliders amber — the usual
/// DCC convention; the selected entity's outline is drawn thicker. Mirrors
/// the simulation, which **ignores `Transform::scale`**, so the outline
/// shows the actual shape rapier builds (translation + rotation only).
/// Toggled from View ▸ Show Colliders.
fn paint_collider_gizmos(&self, ui: &egui::Ui, tab_rect: egui::Rect) {
use oxide_engine::math::Vec3;
let Some(overlay) = self.viewport_overlay else {
return;
};
let scene = &self.state.scene;
// Snapshot colliders so the query borrow is released before we resolve
// world transforms (which re-borrow the scene).
let colliders: Vec<(Entity, oxide_physics::Collider)> = scene
.world()
.query::<&oxide_physics::Collider>()
.iter()
.map(|(e, c)| (e, *c))
.collect();
if colliders.is_empty() {
return;
}
let painter = ui.painter_at(tab_rect);
for (entity, col) in colliders {
// Honor the same visibility rules the renderer/picking use.
if scene.is_component_disabled(entity, "Collider") {
continue;
}
if !scene.is_effectively_enabled(entity).unwrap_or(true) {
continue;
}
let Some(world) = scene.world_transform(entity) else {
continue;
};
// Physics ignores scale; build the pose from translation + rotation
// only so the wireframe matches the simulated shape exactly.
let pose = oxide_engine::math::Transform {
translation: world.translation,
rotation: world.rotation,
scale: Vec3::ONE,
};
let color = if col.sensor {
egui::Color32::from_rgb(235, 200, 90) // amber: trigger
} else {
egui::Color32::from_rgb(110, 220, 120) // green: solid
};
let selected = self.state.selected == Some(entity);
let stroke = egui::Stroke::new(if selected { 2.0_f32 } else { 1.2_f32 }, color);
for (a, b) in collider_wire_segments(&col) {
let (Some(pa), Some(pb)) = (
project(pose.transform_point(a), &overlay.view_proj, tab_rect),
project(pose.transform_point(b), &overlay.view_proj, tab_rect),
) else {
continue;
};
painter.line_segment([pa, pb], stroke);
}
}
}
/// Paints the **View ▸ Raycast Probe** visualization (Stage 9 piece 8c): the
/// frozen probe ray in cyan, and on a hit a small magenta marker at the
/// surface point with a short segment along the surface normal. Misses draw
/// the full ray to its probe distance. The ray lives in world space (the
/// host froze it on a click), so this projects it with the current camera —
/// orbit and the ray reads as a 3D line. Mirrors `paint_collider_gizmos`.
fn paint_raycast_probe(&self, ui: &egui::Ui, tab_rect: egui::Rect) {
let (Some(overlay), Some(viz)) = (self.viewport_overlay, self.raycast_probe_viz) else {
return;
};
let painter = ui.painter_at(tab_rect);
let cyan = egui::Color32::from_rgb(80, 200, 220);
let magenta = egui::Color32::from_rgb(230, 90, 210);
// The ray itself.
if let (Some(a), Some(b)) = (
project(viz.origin, &overlay.view_proj, tab_rect),
project(viz.end, &overlay.view_proj, tab_rect),
) {
painter.line_segment([a, b], egui::Stroke::new(1.5_f32, cyan));
}
// The hit surface: a dot at the contact point + a normal whisker.
if let Some(hit) = viz.hit {
if let Some(p) = project(hit.point, &overlay.view_proj, tab_rect) {
painter.circle_filled(p, 3.5, magenta);
}
// Scale the normal whisker with the gizmo size so it reads at any
// zoom (same trick the gizmo arrows use).
let tip = probe_normal_tip(hit.point, hit.normal, overlay.gizmo_size);
if let (Some(a), Some(b)) = (
project(hit.point, &overlay.view_proj, tab_rect),
project(tip, &overlay.view_proj, tab_rect),
) {
painter.line_segment([a, b], egui::Stroke::new(2.0_f32, magenta));
}
}
}
fn paint_gizmo_overlay(&self, ui: &egui::Ui, tab_rect: egui::Rect) {
let Some(overlay) = self.viewport_overlay else {
return;
};
let Some(selected) = self.state.selected else {
return;
};
let Some(transform) = self.state.scene.world_transform(selected) else {
return;
};
let origin = transform.translation;
// The engaged handle, if a drag is in flight — highlighted so the
// user sees what they're dragging.
let active = self.state.gizmo.drag.as_ref().map(|d| d.handle);
let painter = ui.painter_at(tab_rect);
match self.state.gizmo.mode {
GizmoMode::Translate => {
for axis in Axis3::ALL {
let tip = origin + axis.unit() * overlay.gizmo_size;
self.draw_axis_line(
&painter,
tab_rect,
&overlay,
origin,
tip,
axis_color(axis, active == Some(GizmoHandle::TranslateAxis(axis))),
);
}
for plane in PlaneAxis::ALL {
self.draw_plane_quad(
&painter,
tab_rect,
&overlay,
origin,
plane,
active == Some(GizmoHandle::TranslatePlane(plane)),
);
}
}
GizmoMode::Rotate => {
for axis in Axis3::ALL {
self.draw_axis_circle(
&painter,
tab_rect,
&overlay,
origin,
axis,
active == Some(GizmoHandle::RotateAxis(axis)),
);
}
}
GizmoMode::Scale => {
for axis in Axis3::ALL {
let tip = origin + axis.unit() * overlay.gizmo_size;
self.draw_axis_line(
&painter,
tab_rect,
&overlay,
origin,
tip,
axis_color(axis, active == Some(GizmoHandle::ScaleAxis(axis))),
);
// Solid cube at the tip distinguishes scale from translate
// visually.
if let Some(p) = project(tip, &overlay.view_proj, tab_rect) {
let r = 6.0;
painter.rect_filled(
egui::Rect::from_center_size(p, egui::vec2(r * 2.0, r * 2.0)),
0.0,
axis_color(axis, active == Some(GizmoHandle::ScaleAxis(axis))),
);
}
}
// Center uniform handle.
if let Some(p) = project(origin, &overlay.view_proj, tab_rect) {
let r = 6.0;
painter.rect_filled(
egui::Rect::from_center_size(p, egui::vec2(r * 2.0, r * 2.0)),
2.0,
if active == Some(GizmoHandle::ScaleUniform) {
egui::Color32::WHITE
} else {
egui::Color32::LIGHT_GRAY
},
);
}
}
}
}
fn draw_axis_line(
&self,
painter: &egui::Painter,
tab_rect: egui::Rect,
overlay: &ViewportOverlay,
a: oxide_engine::math::Vec3,
b: oxide_engine::math::Vec3,
color: egui::Color32,
) {
let (Some(pa), Some(pb)) = (
project(a, &overlay.view_proj, tab_rect),
project(b, &overlay.view_proj, tab_rect),
) else {
return;
};
painter.line_segment([pa, pb], egui::Stroke::new(2.5_f32, color));
// Arrowhead at the tip — a small filled triangle perpendicular to
// the line direction in screen space.
let dir = (pb - pa).normalized();
if dir.length_sq() > 0.0 {
let perp = egui::vec2(-dir.y, dir.x);
let base = pb - dir * 8.0;
let p1 = base + perp * 4.0;
let p2 = base - perp * 4.0;
painter.add(egui::Shape::convex_polygon(
vec![pb, p1, p2],
color,
egui::Stroke::NONE,
));
}
}
fn draw_plane_quad(
&self,
painter: &egui::Painter,
tab_rect: egui::Rect,
overlay: &ViewportOverlay,
origin: oxide_engine::math::Vec3,
plane: PlaneAxis,
engaged: bool,
) {
let (a, b) = plane.axes();
let s = overlay.gizmo_size;
// The quad lives at 0.3..0.7 of the gizmo size along each in-plane
// axis, matching what `gizmo::hit_test` claims for the same handle.
let corners = [
origin + a * (s * 0.3) + b * (s * 0.3),
origin + a * (s * 0.7) + b * (s * 0.3),
origin + a * (s * 0.7) + b * (s * 0.7),
origin + a * (s * 0.3) + b * (s * 0.7),
];
let projected: Vec<egui::Pos2> = corners
.iter()
.filter_map(|p| project(*p, &overlay.view_proj, tab_rect))
.collect();
if projected.len() != 4 {
return;
}
let normal_axis = plane.normal();
let color = if normal_axis == oxide_engine::math::Vec3::Z {
egui::Color32::from_rgba_unmultiplied(80, 120, 255, if engaged { 220 } else { 130 })
} else if normal_axis == oxide_engine::math::Vec3::Y {
egui::Color32::from_rgba_unmultiplied(120, 255, 120, if engaged { 220 } else { 130 })
} else {
egui::Color32::from_rgba_unmultiplied(255, 120, 120, if engaged { 220 } else { 130 })
};
let stroke = egui::Stroke::new(1.5_f32, color);
painter.add(egui::Shape::convex_polygon(projected, color, stroke));
}
fn draw_axis_circle(
&self,
painter: &egui::Painter,
tab_rect: egui::Rect,
overlay: &ViewportOverlay,
origin: oxide_engine::math::Vec3,
axis: Axis3,
engaged: bool,
) {
// Pick two in-plane unit vectors orthogonal to the axis.
let n = axis.unit();
let (u, v) = orthonormal_basis(n);
let r = overlay.gizmo_size;
let segs = 48;
let mut pts: Vec<egui::Pos2> = Vec::with_capacity(segs + 1);
for i in 0..=segs {
let t = (i as f32) * std::f32::consts::TAU / (segs as f32);
let world = origin + u * (r * t.cos()) + v * (r * t.sin());
if let Some(p) = project(world, &overlay.view_proj, tab_rect) {
pts.push(p);
}
}
if pts.len() < 2 {
return;
}
let color = axis_color(axis, engaged);
painter.add(egui::Shape::line(pts, egui::Stroke::new(2.0_f32, color)));
}
fn hierarchy(&mut self, ui: &mut egui::Ui) {
// Snapshot the prefab names once so the add-menus can list them without
// borrowing the registry inside the UI closures.
let prefab_names: Vec<String> = self
.state
.prefab_registry
.names()
.map(String::from)
.collect();
ui.horizontal(|ui| {
ui.menu_button(" Root", |ui| {
for name in &prefab_names {
if ui.button(name).clicked() {
self.pending
.push(PendingAction::AddRootPrefab(name.clone()));
ui.close();
}
}
});
let has_sel = self.state.selected.is_some();
ui.add_enabled_ui(has_sel, |ui| {
ui.menu_button(" Child", |ui| {
if let Some(sel) = self.state.selected {
for name in &prefab_names {
if ui.button(name).clicked() {
self.pending
.push(PendingAction::AddChildPrefab(sel, name.clone()));
ui.close();
}
}
}
});
});
if ui
.add_enabled(has_sel, egui::Button::new("🗑 Delete"))
.clicked()
{
if let Some(sel) = self.state.selected {
self.pending.push(PendingAction::Delete(sel));
}
}
});
ui.separator();
ui.weak("Drag a node to move it — nest by dropping on a node, reorder by dropping between nodes. Right-click for actions.");
let rows = snapshot(&self.state.scene);
let selected_entity = self.state.selected;
// Drag state for the insertion indicator: which entity is being
// dragged, where the pointer is, and whether it was released this frame.
let dragged = egui::DragAndDrop::payload::<Entity>(ui.ctx()).map(|a| *a);
let pointer = ui.ctx().pointer_interact_pos();
let released = ui.input(|i| i.pointer.any_released());
// Collect interactions into locals so the deeply-nested drag/menu
// closures never borrow `self`; apply them after the ScrollArea.
let mut queued: Vec<PendingAction> = Vec::new();
let mut new_selection: Option<(Entity, String)> = None;
// The drop chosen on release: (row index, zone) — `usize::MAX` row with
// `RootEnd` means "append to the root level".
let mut drop_action: Option<(usize, DropZone)> = None;
egui::ScrollArea::vertical().show(ui, |ui| {
if rows.is_empty() {
ui.weak("(empty scene — right-click to add a root)");
}
for (i, row) in rows.iter().enumerate() {
let row_resp = ui
.horizontal(|ui| {
ui.add_space(row.depth as f32 * 16.0);
let mut enabled = row.enabled;
if ui.checkbox(&mut enabled, "").changed() {
queued.push(PendingAction::SetEnabled(row.entity, enabled));
}
let label = if row.name.is_empty() {
"(unnamed)".to_owned()
} else {
row.name.clone()
};
// Grey the whole subtree when an ancestor is disabled,
// not just the node whose own flag is off.
let text = if row.effective_enabled {
egui::RichText::new(label)
} else {
egui::RichText::new(label).weak().italics()
};
let selected = selected_entity == Some(row.entity);
// One widget senses everything: click (select),
// secondary-click (context menu), AND drag (move). The
// earlier `dnd_drag_source` wrapper claimed the press
// for itself and the inner label never saw clicks, so
// selection and the right-click menu silently broke.
// Doing it as one click_and_drag widget keeps the
// press attributed to *this* response: a still
// press+release is a click, a press+move is a drag,
// and a secondary-click is the context menu — no
// ambiguity between layered widgets.
let label_resp = ui
.selectable_label(selected, text)
.interact(egui::Sense::click_and_drag());
if label_resp.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), row.entity);
}
if label_resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
if label_resp.clicked() {
new_selection = Some((row.entity, row.name.clone()));
}
label_resp.context_menu(|ui| {
ui.menu_button(" Add Child", |ui| {
for name in &prefab_names {
if ui.button(name).clicked() {
queued.push(PendingAction::AddChildPrefab(
row.entity,
name.clone(),
));
ui.close();
}
}
});
if ui.button("Duplicate").clicked() {
queued.push(PendingAction::Duplicate(row.entity));
ui.close();
}
ui.separator();
if ui.button("🗑 Delete").clicked() {
queued.push(PendingAction::Delete(row.entity));
ui.close();
}
});
})
.response;
// Insertion indicator: while dragging some *other* node over
// this row, split it into before / nest / after zones, draw the
// hint, and capture the drop on release.
if let (Some(drag_e), Some(pos)) = (dragged, pointer) {
let r = row_resp.rect;
if drag_e != row.entity && r.y_range().contains(pos.y) && pos.x >= r.left() {
let t = (pos.y - r.top()) / r.height().max(1.0);
let zone = if t < 0.25 {
DropZone::Before
} else if t > 0.75 {
DropZone::After
} else {
DropZone::Child
};
let accent = ui.visuals().selection.bg_fill;
let line = egui::Stroke::new(2.0_f32, accent);
match zone {
DropZone::Before => {
ui.painter().line_segment(
[
egui::pos2(r.left(), r.top()),
egui::pos2(r.right(), r.top()),
],
line,
);
}
DropZone::After => {
ui.painter().line_segment(
[
egui::pos2(r.left(), r.bottom()),
egui::pos2(r.right(), r.bottom()),
],
line,
);
}
DropZone::Child => {
let fill = egui::Color32::from_rgba_unmultiplied(
accent.r(),
accent.g(),
accent.b(),
60,
);
ui.painter().rect_filled(r, 2.0, fill);
}
DropZone::RootEnd => {}
}
if released {
drop_action = Some((i, zone));
}
}
}
}
// Empty area below the rows: right-click to add a root, or drop a
// dragged node here to move it to the end of the root level.
let empty = ui.allocate_response(ui.available_size(), egui::Sense::click());
empty.context_menu(|ui| {
ui.menu_button(" Add Root", |ui| {
for name in &prefab_names {
if ui.button(name).clicked() {
queued.push(PendingAction::AddRootPrefab(name.clone()));
ui.close();
}
}
});
});
if let (Some(_), Some(pos)) = (dragged, pointer) {
if released && empty.rect.contains(pos) {
drop_action = Some((usize::MAX, DropZone::RootEnd));
}
}
});
// Resolve the chosen drop into a single reorder (parent + before).
if let (Some(drag_e), Some((i, zone))) = (dragged, drop_action) {
let (new_parent, before) = match zone {
DropZone::RootEnd => (None, None),
DropZone::Child => (Some(rows[i].entity), None),
DropZone::Before => (
self.state.scene.parent(rows[i].entity),
Some(rows[i].entity),
),
DropZone::After => {
let target = rows[i].entity;
let parent = self.state.scene.parent(target);
let siblings: Vec<Entity> = match parent {
Some(p) => self.state.scene.children(p).to_vec(),
None => self.state.scene.roots().to_vec(),
};
let before = siblings
.iter()
.position(|&e| e == target)
.and_then(|idx| siblings.get(idx + 1).copied());
(parent, before)
}
};
if let Err(err) = self.state.scene.reorder(drag_e, new_parent, before) {
log::debug!("reorder rejected: {err}");
}
}
if let Some((entity, name)) = new_selection {
self.state.selected = Some(entity);
*self.rename_buf = name;
*self.euler_for = None;
}
self.pending.extend(queued);
}
fn inspector(&mut self, ui: &mut egui::Ui) {
let Some(selected) = self.state.selected else {
ui.weak("Select a node to inspect it.");
return;
};
if !self.state.scene.contains(selected) {
self.state.selected = None;
return;
}
ui.horizontal(|ui| {
ui.label("Name");
let edited = ui.text_edit_singleline(self.rename_buf);
if edited.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
let cmd = RenameCmd::new(self.state, selected, self.rename_buf.clone());
self.commands.push(cmd, self.state);
}
});
let mut enabled = self.state.scene.is_enabled(selected).unwrap_or(true);
if ui.checkbox(&mut enabled, "Enabled").changed() {
self.pending
.push(PendingAction::SetEnabled(selected, enabled));
}
// Parent is shown read-only here — reparenting is done by drag-and-drop
// in the Hierarchy panel (a dropdown listing every entity doesn't scale
// to large scenes).
let parent_label = match self.state.scene.parent(selected) {
None => "(root)".to_owned(),
Some(p) => self
.state
.scene
.name(p)
.unwrap_or_else(|| "(unnamed)".into()),
};
ui.horizontal(|ui| {
ui.label("Parent");
ui.weak(parent_label);
});
// Two sections: **node-baked** components (Layer + Transform — always
// present on every entity, single-instance, no controls), then
// **modular** components (the user's choice: drag-reorder, disable,
// remove, add).
self.essential_components(ui, selected);
self.reflected_components(ui, selected);
ui.separator();
self.add_component_menu(ui, selected);
}
/// Renders the node-baked components in their fixed canonical order. These
/// are inherent to every entity — auto-attached on `Scene::spawn` — so
/// they don't carry the drag handle / enable checkbox / remove button that
/// modular components do.
///
/// Layer is shown above Transform per the maintainer's preference: "which
/// world is this entity in?" precedes "where in that world is it?". Node's
/// own fields are already shown by the Name / Enabled header above, so it
/// isn't repeated here.
fn essential_components(&mut self, ui: &mut egui::Ui, selected: Entity) {
ui.separator();
ui.heading("Layer");
self.layers_widget(ui, selected);
ui.separator();
ui.heading("Groups");
self.groups_widget(ui, selected);
ui.separator();
ui.heading("Transform");
self.essential_fields(ui, selected, "Transform");
}
/// Renders one essential component's reflected fields with no extras —
/// no checkbox, no remove, no drag. Edits route through `SetFieldCmd`
/// like modular fields do.
fn essential_fields(&mut self, ui: &mut egui::Ui, selected: Entity, type_name: &'static str) {
// Snapshot the field rows up front so we can drop the registry/world
// borrow before calling field_widget (which borrows &mut self for the
// euler buffer).
struct Row {
info: FieldInfo,
variants: Option<&'static [&'static str]>,
value: String,
}
let rows: Vec<Row> = {
let world = self.state.scene.world();
let Ok(infos) = self.state.registry.field_infos(world, selected, type_name) else {
return;
};
infos
.iter()
.filter_map(|info| {
self.state
.registry
.get_field(world, selected, type_name, info.name)
.ok()
.map(|value| Row {
info: *info,
variants: self.state.registry.enum_variants(info.type_name),
value,
})
})
.collect()
};
let mut edits: Vec<(&'static str, String)> = Vec::new();
// Namespace by component so a field name shared with another component
// can't produce a duplicate egui widget id (see the modular loop).
ui.push_id(type_name, |ui| {
for row in &rows {
if let Some(new_ron) =
self.field_widget(ui, selected, type_name, &row.info, row.variants, &row.value)
{
edits.push((row.info.name, new_ron));
}
}
});
for (field, ron) in edits {
if let Some(cmd) = SetFieldCmd::new(self.state, selected, type_name, field, ron) {
self.commands.push(cmd, self.state);
}
}
}
/// The `Layer` component widget: a single-choice dropdown of layer names.
///
/// Each entity belongs to exactly one layer (the Unity model — per-entity
/// membership is single, filters are masks). The dropdown shows every
/// named layer from [`LayerRegistry`](oxide_engine::layer::LayerRegistry)
/// plus a `"Layer N"` fallback for unnamed slots, and an
/// **"Edit names…"** entry that opens the layer-editor modal so a project
/// can define names like `"Player"`, `"Enemy"`, `"World"`.
fn layers_widget(&mut self, ui: &mut egui::Ui, selected: Entity) {
use oxide_engine::layer::{Layer, MAX_LAYERS};
let current_index: u32 = match self.state.scene.world().get::<&Layer>(selected) {
Ok(l) => l.index,
Err(_) => {
ui.weak("(no Layer — internal error: should be auto-attached)");
return;
}
};
let label_for = |i: u32, reg: &oxide_engine::layer::LayerRegistry| -> String {
reg.name(i)
.map(String::from)
.unwrap_or_else(|| format!("Layer {i}"))
};
let current_label = label_for(current_index, &self.state.layer_registry);
let mut chosen: Option<u32> = None;
let mut open_editor = false;
ui.horizontal(|ui| {
ui.label("Layer");
egui::ComboBox::from_id_salt(("oxide.layer.dropdown", selected))
.selected_text(current_label)
.show_ui(ui, |ui| {
for i in 0..MAX_LAYERS {
let name = label_for(i, &self.state.layer_registry);
if ui.selectable_label(i == current_index, name).clicked() {
chosen = Some(i);
}
}
ui.separator();
if ui.button("✏ Edit names…").clicked() {
open_editor = true;
}
});
});
if let Some(new_index) = chosen {
if new_index != current_index {
let new = Layer::on(new_index);
if let Ok(ron) = ron::to_string(&new_index) {
if let Some(cmd) = SetFieldCmd::new(self.state, selected, "Layer", "index", ron)
{
self.commands.push(cmd, self.state);
} else {
// Fall back to a direct mutation if the registry path
// somehow refuses (shouldn't happen for the auto-
// attached Layer component).
if let Ok(mut l) = self.state.scene.world_mut().get::<&mut Layer>(selected)
{
*l = new;
}
}
}
}
}
if open_editor {
*self.show_layer_editor = true;
}
}
/// The `Groups` widget: a **multi-select** dropdown of the project's defined
/// group names, each a checkbox toggling this entity's membership.
///
/// Groups are the multi-valued counterpart to the single-select `Layer`
/// above — an entity is on one layer but in any number of groups. Membership
/// lives in the entity's [`Tags`](oxide_engine::layer::Tags) component
/// (created lazily on first add); the dropdown's vocabulary comes from
/// [`group_registry`](EditorState::group_registry). An **"Edit groups…"**
/// entry opens the Groups editor to define new groups. Any tag an entity
/// carries that is *not* a defined group is surfaced below as an
/// "ungrouped tag" so it stays visible (e.g. one a script set, or a group
/// later deleted from the project).
fn groups_widget(&mut self, ui: &mut egui::Ui, selected: Entity) {
use oxide_engine::layer::Tags;
// Snapshot the entity's current group membership and the project's
// defined groups, so the egui closures don't borrow the world/registry
// while we also need &mut self for the mutation below.
let current: Vec<String> = self
.state
.scene
.world()
.get::<&Tags>(selected)
.map(|t| t.iter().map(String::from).collect())
.unwrap_or_default();
let defined: Vec<String> = self.state.group_registry.iter().map(String::from).collect();
// Defined groups the node isn't in yet — the "add" menu's contents.
let available: Vec<String> = defined
.iter()
.filter(|d| !current.iter().any(|c| c == *d))
.cloned()
.collect();
// (group name, now a member?) — applied after the UI closures so the
// world isn't borrowed while egui is mid-render.
let mut change: Option<(String, bool)> = None;
let mut open_editor = false;
ui.horizontal(|ui| {
ui.label("Groups");
// "" opens a menu of defined groups not yet on this node.
ui.menu_button("", |ui| {
if available.is_empty() {
if defined.is_empty() {
ui.weak("(no groups defined)");
} else {
ui.weak("(already in every group)");
}
}
for name in &available {
if ui.button(name).clicked() {
change = Some((name.clone(), true));
ui.close();
}
}
ui.separator();
if ui.button("✏ Edit groups…").clicked() {
open_editor = true;
ui.close();
}
});
});
// Each group the node is in renders as its own removable row — so a
// group can always be removed, including one whose definition was later
// deleted from the project (shown as "(undefined)" but still removable).
if current.is_empty() {
ui.weak("(not in any group)");
}
for name in &current {
ui.horizontal(|ui| {
if ui
.small_button("🗑")
.on_hover_text("Remove from this group")
.clicked()
{
change = Some((name.clone(), false));
}
ui.label(name);
if !defined.iter().any(|d| d == name) {
ui.weak("(undefined)");
}
});
}
if let Some((name, member)) = change {
apply_group_membership(self.state.scene.world_mut(), selected, &name, member);
}
if open_editor {
*self.show_group_editor = true;
}
}
/// "Add Component ▾" menu. Lists every addable type registered in the
/// reflection registry. For each:
///
/// - If the entity **doesn't** already carry it → "*Name*" attaches it.
/// - If it **does** → "*Name* (as child)" spawns a new child entity with
/// the component already attached and selects it. This is Oxide's
/// answer to "multiple of the same type": archetypal ECS allows one
/// per type per entity, so a second mesh / collider / etc. lives on a
/// child entity (Bevy's pattern). The user still gets the workflow
/// from a single menu click.
fn add_component_menu(&mut self, ui: &mut egui::Ui, selected: Entity) {
enum AddChoice {
HereIfAbsent(&'static str),
AsChild(&'static str),
}
// Snapshot per-type presence before opening the menu so the closure
// doesn't have to borrow self twice.
let present_set: Vec<(&'static str, bool)> = {
let world = self.state.scene.world();
self.state
.registry
.addable_names()
.map(|n| {
(
n,
self.state.registry.has(world, selected, n).unwrap_or(false),
)
})
.collect()
};
let mut chosen: Option<AddChoice> = None;
ui.menu_button(" Add Component ▾", |ui| {
if present_set.is_empty() {
ui.weak("(no addable components registered)");
}
for (name, present) in &present_set {
if !present {
if ui.button(*name).clicked() {
chosen = Some(AddChoice::HereIfAbsent(name));
ui.close();
}
} else {
// Already present: offer it as a child instead, so the
// user can still "add another mesh / collider / …" from
// one menu without breaking the ECS one-per-type rule.
if ui
.button(format!("{name} (as child)"))
.on_hover_text("Spawns a new child entity carrying this component")
.clicked()
{
chosen = Some(AddChoice::AsChild(name));
ui.close();
}
}
}
});
match chosen {
None => {}
Some(AddChoice::HereIfAbsent(name)) => {
let added = matches!(
self.state
.registry
.add_default(self.state.scene.world_mut(), selected, name),
Ok(true)
);
if added {
self.state
.component_order
.entry(selected)
.or_default()
.push(name);
}
}
Some(AddChoice::AsChild(name)) => {
// Spawn a child entity with the component attached and select
// it. Name reflects the type so the hierarchy shows what it is.
let child_name = name.to_string();
let child =
self.state
.scene
.spawn_child(selected, child_name.clone(), Transform::IDENTITY);
let _ = self
.state
.registry
.add_default(self.state.scene.world_mut(), child, name);
self.state
.component_order
.entry(child)
.or_default()
.push(name);
self.state.selected = Some(child);
*self.rename_buf = child_name;
*self.euler_for = None;
}
}
}
/// Renders every *reflected* component on the entity by walking the
/// reflection registry — one heading per component, one widget per field —
/// instead of a hand-written panel per type. This is what lets a brand-new
/// component type appear in the inspector with no editor code: derive
/// `Reflect`, register it, done.
///
/// `Node` is skipped because its fields (`name`, `enabled`) are already
/// shown by the bespoke header above.
fn reflected_components(&mut self, ui: &mut egui::Ui, selected: Entity) {
/// One field's descriptor + its current value as RON, plus the enum
/// variant list when the field's type is a registered enum (so phase 2
/// can render a dropdown). Snapshotted so we don't hold a registry/world
/// borrow across the egui render + the command push that follows.
struct FieldRow {
info: FieldInfo,
variants: Option<&'static [&'static str]>,
value: String,
}
struct CompRows {
name: &'static str,
/// Whether the component is currently in the entity's
/// `DisabledComponents` set — drives the per-component checkbox.
disabled: bool,
fields: Vec<FieldRow>,
}
// Phase 1: gather metadata + current values (immutable borrows only).
let mut comps: Vec<CompRows> = Vec::new();
{
let world = self.state.scene.world();
let present: Vec<&'static str> = self
.state
.registry
.components_on(world, selected)
.into_iter()
.filter(|n| !is_essential_component(n))
.collect();
let saved = self
.state
.component_order
.get(&selected)
.map(Vec::as_slice)
.unwrap_or(&[]);
for name in inspector_component_order(&present, saved) {
let Ok(infos) = self.state.registry.field_infos(world, selected, name) else {
continue;
};
let fields = infos
.iter()
.filter_map(|info| {
self.state
.registry
.get_field(world, selected, name, info.name)
.ok()
.map(|value| FieldRow {
info: *info,
variants: self.state.registry.enum_variants(info.type_name),
value,
})
})
.collect();
let disabled = self.state.scene.is_component_disabled(selected, name);
comps.push(CompRows {
name,
disabled,
fields,
});
}
}
// Phase 2: render, collecting field edits + removals + per-component
// disable toggles + drag-reorder. The euler buffer for quat fields
// needs &mut self, so this can't hold the phase-1 borrows.
let mut edits: Vec<(&'static str, &'static str, String)> = Vec::new();
let mut remove: Option<&'static str> = None;
let mut toggle: Option<(&'static str, bool)> = None;
// Drag-reorder state: payload is the component name being dragged,
// pointer + released drive the drop decision.
let dragged = egui::DragAndDrop::payload::<&'static str>(ui.ctx()).map(|a| *a);
let pointer = ui.ctx().pointer_interact_pos();
let released = ui.input(|i| i.pointer.any_released());
// (dragged, target, place_before) on release.
let mut reorder: Option<(&'static str, &'static str, bool)> = None;
for comp in &comps {
ui.separator();
let row_resp = ui
.horizontal(|ui| {
// Enable/disable checkbox.
let mut enabled = !comp.disabled;
if ui
.checkbox(&mut enabled, "")
.on_hover_text("Enable / disable this component")
.changed()
{
toggle = Some((comp.name, !enabled));
}
// Visually dim a disabled component's heading so it's clear
// systems will skip it. (No leading grip glyph — the hover
// Grab cursor + tooltip signal the drag affordance, and the
// braille grip rendered as a tofu box in egui's font.)
let title = if comp.disabled {
egui::RichText::new(comp.name).heading().weak().italics()
} else {
egui::RichText::new(comp.name).heading().strong()
};
// The title **is** the drag handle. It must be a
// *non-selectable* Label: `ui.heading()` builds a selectable
// label, so a press-drag highlighted the text instead of
// starting a drag. `Label::selectable(false)` + a
// click-and-drag sense fixes that while keeping a hit rect
// big enough for egui's drag detection to fire.
let heading = ui
.add(
egui::Label::new(title)
.selectable(false)
.sense(egui::Sense::click_and_drag()),
)
.on_hover_cursor(egui::CursorIcon::Grab)
.on_hover_text("Drag the title to reorder this component");
if heading.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), comp.name);
}
if heading.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
if ui
.small_button("🗑")
.on_hover_text("Remove component")
.clicked()
{
remove = Some(comp.name);
}
})
.response;
// Drop indicator + capture for drag-reorder. Transform is never a
// valid drop target (it's pinned first); a component never drops
// onto itself.
if let (Some(drag_name), Some(pos)) = (dragged, pointer) {
if drag_name != comp.name && comp.name != "Transform" {
let r = row_resp.rect;
if r.contains(pos) {
let above = (pos.y - r.top()) < r.height() / 2.0;
let accent = ui.visuals().selection.bg_fill;
let line = egui::Stroke::new(2.0_f32, accent);
let y = if above { r.top() } else { r.bottom() };
ui.painter().line_segment(
[egui::pos2(r.left(), y), egui::pos2(r.right(), y)],
line,
);
if released {
reorder = Some((drag_name, comp.name, above));
}
}
}
}
// Namespace every field widget by the component name. Two different
// components can share a field name (e.g. both `MeshRenderer` and
// `Collider` have a `shape`), and some field widgets derive a stable
// egui id from `(entity, field_name)` only — without this scope those
// ids collide and egui flags a duplicate-id error, blocking edits.
ui.push_id(comp.name, |ui| {
for row in &comp.fields {
if let Some(new_ron) = self.field_widget(
ui,
selected,
comp.name,
&row.info,
row.variants,
&row.value,
) {
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));
}
}
});
}
// Phase 3a: apply field edits through the undo stack. SetFieldCmd's
// merge hook coalesces a drag into one undo entry.
for (type_name, field, ron) in edits {
if let Some(cmd) = SetFieldCmd::new(self.state, selected, type_name, field, ron) {
self.commands.push(cmd, self.state);
}
}
// Phase 3b: component removal. Not undoable yet — like Add/Delete in
// the hierarchy, structural changes bypass the command stack.
if let Some(type_name) = remove {
let _ = self
.state
.registry
.remove(self.state.scene.world_mut(), selected, type_name);
// Drop it from the inspector order too so it doesn't reappear if
// it's later re-added (we want re-adds at the bottom, fresh).
if let Some(order) = self.state.component_order.get_mut(&selected) {
order.retain(|n| *n != type_name);
}
}
// Phase 3c: per-component enable/disable. Toggling rewrites the
// entity's DisabledComponents set (inserted if absent, cleared from
// the entity when no components remain disabled).
if let Some((type_name, disabled)) = toggle {
apply_component_disable(self.state.scene.world_mut(), selected, type_name, disabled);
}
// Phase 3d: drag-reorder. Sync the saved order with anything currently
// displayed (so components that arrived via spawn / set_ron / etc. are
// reorderable too), then move the dragged entry to its drop position.
if let Some((dragged, target, place_before)) = reorder {
let order_entry = self.state.component_order.entry(selected).or_default();
for comp in &comps {
if comp.name != "Transform" && !order_entry.contains(&comp.name) {
order_entry.push(comp.name);
}
}
let new_order =
reorder_component_list(std::mem::take(order_entry), dragged, target, place_before);
*order_entry = new_order;
}
}
/// 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
/// fully generic even for types it has no bespoke widget for.
fn field_widget(
&mut self,
ui: &mut egui::Ui,
entity: Entity,
type_name: &'static str,
info: &FieldInfo,
variants: Option<&'static [&'static str]>,
current: &str,
) -> Option<String> {
// Enum-typed fields with a registered variant list become a dropdown,
// regardless of the syntactic type_name. The chosen name is already
// valid RON for the unit variant (see ReflectEnum docs).
if let Some(variants) = variants {
let mut current_owned = current.to_owned();
let mut chosen: Option<String> = None;
ui.horizontal(|ui| {
ui.label(info.name);
egui::ComboBox::from_id_salt(("oxide.field.enum", entity, info.name))
.selected_text(&current_owned)
.show_ui(ui, |ui| {
for v in variants {
if ui.selectable_label(current_owned == *v, *v).clicked() {
current_owned = (*v).to_owned();
chosen = Some((*v).to_owned());
}
}
});
});
return chosen;
}
// Asset-reference fields (`AssetRef<T>` / `Handle<T>`) get a picker that
// lists the project's assets of the matching kind, filtered by the
// field's target type. Recognised by the field's syntactic type name.
if let Some(target) = asset_ref_target(info.type_name) {
return self.asset_ref_field_widget(ui, entity, info, target, current);
}
match info.type_name {
"f32" => {
let mut v: f32 = ron::from_str(current).ok()?;
let changed = ui
.horizontal(|ui| {
ui.label(info.name);
// `#[reflect(min, max)]` → slider; otherwise unbounded drag.
if let Some((min, max)) = info.range {
ui.add(egui::Slider::new(&mut v, min..=max)).changed()
} else {
ui.add(egui::DragValue::new(&mut v).speed(0.05)).changed()
}
})
.inner;
changed.then(|| ron::to_string(&v).ok()).flatten()
}
"Color" => {
let c: Color = ron::from_str(current).ok()?;
let mut rgba = [c.r, c.g, c.b, c.a];
let changed = ui
.horizontal(|ui| {
ui.label(info.name);
ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed()
})
.inner;
if changed {
ron::to_string(&Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3])).ok()
} else {
None
}
}
"Material" => {
// The basic PBR material renders as one composite block: a
// color picker for albedo + sliders for the normalized
// metallic / roughness factors — the widgets the user expects
// for a surface, instead of a raw RON string. (A future
// recursive struct inspector based on `Reflect` would replace
// this special case generically.)
let mut m: Material = ron::from_str(current).ok()?;
let mut changed = false;
ui.label(info.name);
ui.indent(("oxide.field.material", entity, info.name), |ui| {
let mut rgba = [m.albedo.r, m.albedo.g, m.albedo.b, m.albedo.a];
ui.horizontal(|ui| {
ui.label("albedo");
if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
m.albedo = Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("metallic");
if ui
.add(egui::Slider::new(&mut m.metallic, 0.0..=1.0))
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("roughness");
if ui
.add(egui::Slider::new(&mut m.roughness, 0.0..=1.0))
.changed()
{
changed = true;
}
});
});
if changed {
ron::to_string(&m).ok()
} else {
None
}
}
"bool" => {
let mut v: bool = ron::from_str(current).ok()?;
let changed = ui
.horizontal(|ui| {
ui.label(info.name);
ui.checkbox(&mut v, "").changed()
})
.inner;
changed.then(|| v.to_string())
}
"Vec3" => {
let mut v: Vec3 = ron::from_str(current).ok()?;
vec3_drag(ui, info.name, &mut v, 0.05)
.then(|| ron::to_string(&v).ok())
.flatten()
}
"Quat" => self.quat_field_widget(ui, entity, type_name, info, current),
"String" => {
let mut s: String = ron::from_str(current).ok()?;
let changed = ui
.horizontal(|ui| {
ui.label(info.name);
ui.text_edit_singleline(&mut s).changed()
})
.inner;
changed.then(|| ron::to_string(&s).ok()).flatten()
}
"LayerMask" => {
// A multi-select dropdown of named layers — the right control
// for a *filter* mask (e.g. a Camera's visibility), which is
// multi-valued even though an entity's own `Layer` is single.
let mut mask: LayerMask = ron::from_str(current).ok()?;
// Snapshot labels so the combo closure doesn't borrow the
// registry while we mutate `mask`.
let labels: Vec<(u32, String)> = (0..oxide_engine::layer::MAX_LAYERS)
.map(|i| (i, layer_label(i, &self.state.layer_registry)))
.collect();
let summary = mask_summary(mask, &self.state.layer_registry);
let mut changed = false;
ui.horizontal(|ui| {
ui.label(info.name);
egui::ComboBox::from_id_salt(("oxide.field.layermask", entity, info.name))
.selected_text(summary)
.show_ui(ui, |ui| {
for (i, name) in &labels {
let mut on = mask.contains_layer(*i);
if ui.checkbox(&mut on, name).changed() {
mask = mask.toggled(*i);
changed = true;
}
}
});
});
changed.then(|| ron::to_string(&mask).ok()).flatten()
}
// Fallback: edit the raw RON. A bad edit is simply rejected by
// `set_field` (the value stays unchanged), so this is safe.
_ => {
let mut text = current.to_owned();
let changed = ui
.horizontal(|ui| {
ui.label(info.name);
ui.text_edit_singleline(&mut text).changed()
})
.inner;
changed.then_some(text)
}
}
}
/// Renders an asset-reference field as a picker: a dropdown of the project's
/// assets whose [`AssetKind`] matches the field's `target` type, plus a
/// "(none)" entry to clear it. The field's stored value is the asset's
/// stable `AssetUid` (an `AssetRef<T>` serializes as `Option<AssetUid>`), so
/// the returned RON is `Some((uid))` / `None` — independent of the project's
/// location on disk.
fn asset_ref_field_widget(
&self,
ui: &mut egui::Ui,
entity: Entity,
info: &FieldInfo,
target: &str,
current: &str,
) -> Option<String> {
// The current value, if any. A malformed value is treated as empty.
let cur: Option<AssetUid> = ron::from_str(current).unwrap_or(None);
let filter = AssetKind::for_handle_target(target);
let Some(db) = &self.state.asset_db else {
ui.horizontal(|ui| {
ui.label(info.name);
ui.weak("(open a project to pick assets)");
});
return None;
};
// Snapshot the pickable assets (uid + leaf name) so the combo closure
// doesn't borrow `db` while we mutate the selection.
let mut options: Vec<(AssetUid, String)> = db
.entries()
.filter(|e| filter.map_or(true, |k| e.kind == k))
.map(|e| {
let leaf = e.path.rsplit('/').next().unwrap_or(&e.path).to_owned();
(e.uid, leaf)
})
.collect();
options.sort_by(|a, b| a.1.cmp(&b.1));
let selected_text = match cur {
Some(uid) => db
.relative_path(uid)
.map(|p| p.rsplit('/').next().unwrap_or(p).to_owned())
.unwrap_or_else(|| "(missing)".to_owned()),
None => "(none)".to_owned(),
};
let mut chosen: Option<Option<AssetUid>> = None;
ui.horizontal(|ui| {
ui.label(info.name);
egui::ComboBox::from_id_salt(("oxide.field.assetref", entity, info.name))
.selected_text(selected_text)
.show_ui(ui, |ui| {
if ui.selectable_label(cur.is_none(), "(none)").clicked() {
chosen = Some(None);
}
for (uid, leaf) in &options {
if ui.selectable_label(cur == Some(*uid), leaf).clicked() {
chosen = Some(Some(*uid));
}
}
});
});
chosen.and_then(|value| ron::to_string(&value).ok())
}
/// Edits a `Quat` field as Euler degrees — raw quaternion components are
/// not hand-editable. Uses the [`euler_for`](Shell::euler_for) buffer so
/// the displayed angles stay stable across frames instead of jumping from
/// lossy quat↔euler round-trips.
fn quat_field_widget(
&mut self,
ui: &mut egui::Ui,
entity: Entity,
type_name: &'static str,
info: &FieldInfo,
current: &str,
) -> Option<String> {
let q: Quat = ron::from_str(current).ok()?;
let key = (entity, type_name, info.name);
// Re-sync the buffer from the stored quaternion only when we switch to
// a different field, so an in-progress edit isn't perturbed.
if *self.euler_for != Some(key) {
let (rx, ry, rz) = q.to_euler(EulerRot::XYZ);
*self.rot_euler = Vec3::new(rx.to_degrees(), ry.to_degrees(), rz.to_degrees());
*self.euler_for = Some(key);
}
let mut euler = *self.rot_euler;
if vec3_drag(ui, info.name, &mut euler, 0.5) {
*self.rot_euler = euler;
let rotated = Quat::from_euler(
EulerRot::XYZ,
euler.x.to_radians(),
euler.y.to_radians(),
euler.z.to_radians(),
);
ron::to_string(&rotated).ok()
} else {
None
}
}
fn project_panel(&mut self, ui: &mut egui::Ui) {
let Some(project) = &self.state.project else {
ui.weak("No project open.");
ui.label("Open or create one from the File menu.");
return;
};
ui.heading(project.name());
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.
ui.horizontal(|ui| {
ui.label("Assets");
if ui
.small_button("↺ Rescan")
.on_hover_text("Re-read assets/ from disk")
.clicked()
{
if let Some(db) = &mut self.state.asset_db {
db.scan();
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);
ui.separator();
let project = self.state.project.as_ref().unwrap();
show_project_tree(ui, "scenes/", project.scenes_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,
/// and a property panel (including the font-asset picker) — saved as a
/// `ui/` asset. Structural and property edits route through
/// [`SetUiPanelCmd`](crate::commands::SetUiPanelCmd) so they undo.
fn ui_canvas_panel(&mut self, ui: &mut egui::Ui) {
if self.state.ui_doc.is_none() {
ui.weak("No UI document open.");
if ui.button(" New UI Document").clicked() {
self.state.ui_doc = Some(crate::state::UiDoc::new());
}
self.ui_canvas_open_list(ui);
return;
}
// Render from clones so we can mutate state / push commands afterwards
// without holding a borrow of `self.state.ui_doc`.
let doc = self.state.ui_doc.as_ref().unwrap();
let panel = doc.panel.clone();
let selected = doc.selected.clone();
let dirty = doc.dirty;
let mut new_selection: Option<WidgetPath> = None;
let mut new_panel: Option<(UiPanel, String)> = None;
let mut do_save = false;
let mut do_close = false;
// --- Toolbar --------------------------------------------------------
ui.horizontal(|ui| {
if ui.button("💾 Save").clicked() {
do_save = true;
}
// Add a widget as a child of the selection (if it's a container),
// else as a child of the root.
ui.menu_button(" Add ▾", |ui| {
for name in ["Leaf", "Row", "Column", "Grid", "Anchor"] {
if ui.button(name).clicked() {
let widget = match name {
"Row" => Widget::row(),
"Column" => Widget::column(),
"Grid" => Widget::grid(2, 2),
"Anchor" => Widget::anchor(),
_ => Widget::leaf(Vec2::new(120.0, 32.0)),
};
let parent = if panel
.root
.get_path(&selected)
.is_some_and(Widget::is_container)
{
selected.clone()
} else {
WidgetPath::root()
};
let mut next = panel.clone();
if next.root.push_child_at(&parent, widget) {
let count = next
.root
.get_path(&parent)
.map_or(0, |w| w.children().len());
new_selection = Some(parent.child(count.saturating_sub(1)));
new_panel = Some((next, format!("Add {name}")));
}
ui.close();
}
}
});
if !selected.is_root() && ui.button("🗑 Remove").clicked() {
let mut next = panel.clone();
if next.root.remove_path(&selected).is_some() {
new_selection = Some(WidgetPath::root());
new_panel = Some((next, "Remove widget".to_owned()));
}
}
if dirty {
ui.weak("● unsaved");
}
if ui.button("Close").clicked() {
do_close = true;
}
});
ui.separator();
// Everything below scrolls, so the property panel is always reachable
// even when the canvas tab is short.
egui::ScrollArea::vertical().show(ui, |ui| {
// --- Widget tree ------------------------------------------------
egui::CollapsingHeader::new("Widget tree")
.default_open(true)
.show(ui, |ui| {
ui_widget_tree(
ui,
&panel.root,
WidgetPath::root(),
&selected,
&mut new_selection,
);
});
ui.separator();
// --- Preview ----------------------------------------------------
ui.label("Preview");
draw_ui_preview(ui, &panel);
ui.separator();
// --- Properties of the selected widget --------------------------
if let Some(widget) = panel.root.get_path(&selected) {
let mut edited = widget.clone();
ui.label(format!("Properties — {}", widget_label(widget, &selected)));
if self.ui_widget_properties(ui, &mut edited, &selected) {
let mut next = panel.clone();
if let Some(slot) = next.root.get_path_mut(&selected) {
*slot = edited;
new_panel = Some((next, "Edit widget".to_owned()));
}
}
}
});
// --- Apply collected actions ---------------------------------------
if let Some(sel) = new_selection {
if let Some(doc) = &mut self.state.ui_doc {
doc.selected = sel;
}
}
if let Some((after, label)) = new_panel {
let before = self.state.ui_doc.as_ref().unwrap().panel.clone();
self.commands.push(
SetUiPanelCmd {
before,
after,
label,
},
self.state,
);
}
if do_save {
self.save_ui_doc();
}
if do_close {
self.state.ui_doc = None;
}
}
/// Lists the project's `ui/` documents (when a project is open) as buttons
/// that open them into the canvas.
fn ui_canvas_open_list(&mut self, ui: &mut egui::Ui) {
let Some(db) = &self.state.asset_db else {
return;
};
let mut docs: Vec<(AssetUid, String)> = db
.entries_of_kind(AssetKind::Ui)
.map(|e| (e.uid, e.path.clone()))
.collect();
docs.sort_by(|a, b| a.1.cmp(&b.1));
if docs.is_empty() {
return;
}
ui.separator();
ui.label("Open a UI document:");
let mut to_open: Option<(AssetUid, PathBuf)> = None;
for (uid, path) in &docs {
if ui.button(path).clicked() {
if let Some(abs) = db.absolute_path(*uid) {
to_open = Some((*uid, abs));
}
}
}
if let Some((uid, abs)) = to_open {
match std::fs::read_to_string(&abs)
.ok()
.and_then(|t| ron::from_str::<UiPanel>(&t).ok())
{
Some(panel) => {
self.state.ui_doc = Some(crate::state::UiDoc {
panel,
asset: Some(uid),
selected: WidgetPath::root(),
dirty: false,
});
}
None => log::warn!("could not load UI document {}", abs.display()),
}
}
}
/// Property editor for the selected widget. Returns whether anything
/// changed (the caller then records one undoable panel edit). Reads the
/// asset database for the font picker.
fn ui_widget_properties(
&self,
ui: &mut egui::Ui,
widget: &mut Widget,
salt: &WidgetPath,
) -> bool {
let mut changed = false;
let id_salt = ("oxide.uicanvas.props", salt.0.clone());
ui.horizontal(|ui| {
ui.label("id");
let mut id = widget.id.as_str().to_owned();
if ui.text_edit_singleline(&mut id).changed() {
widget.id = id.into();
changed = true;
}
});
// Text content (empty clears it).
ui.horizontal(|ui| {
ui.label("text");
let mut text = widget.text.clone().unwrap_or_default();
if ui.text_edit_singleline(&mut text).changed() {
widget.text = (!text.is_empty()).then_some(text);
changed = true;
}
});
// Kind-specific parameters — what makes each widget type distinct.
changed |= kind_properties(ui, &mut widget.kind, salt);
ui.separator();
changed |= optional_color(ui, "background", &mut widget.visual.background);
changed |= optional_color(ui, "foreground", &mut widget.visual.foreground);
// Font size (optional).
ui.horizontal(|ui| {
let mut on = widget.visual.font_size.is_some();
if ui.checkbox(&mut on, "font size").changed() {
widget.visual.font_size = on.then_some(14.0);
changed = true;
}
if let Some(size) = &mut widget.visual.font_size {
if ui
.add(egui::DragValue::new(size).range(4.0..=200.0))
.changed()
{
changed = true;
}
}
});
// Font asset picker — the AssetRef<Font> end-to-end target.
changed |= self.font_asset_picker(ui, &id_salt, &mut widget.visual.font_asset);
// Layout — how this widget is sized and placed within its parent.
ui.separator();
egui::CollapsingHeader::new("Layout")
.id_salt(("oxide.uicanvas.layout", salt.0.clone()))
.default_open(true)
.show(ui, |ui| {
changed |= sizing_editor(ui, "width", &mut widget.style.width, 0);
changed |= sizing_editor(ui, "height", &mut widget.style.height, 1);
changed |= align_combo(ui, "align x", &mut widget.style.align_horizontal, 0);
changed |= align_combo(ui, "align y", &mut widget.style.align_vertical, 1);
changed |= insets_editor(ui, "padding", &mut widget.style.padding, 0);
changed |= insets_editor(ui, "margin", &mut widget.style.margin, 1);
changed |= anchor_combo(ui, &mut widget.style.anchor, salt);
ui.weak(
"Tip: stacks/grids place children automatically; to move a \
child freely, put it in an Anchor parent and set its anchor.",
);
});
changed
}
/// A dropdown that sets a [`VisualStyle::font_asset`] from the project's
/// font assets (or clears it to inherit). The picker filters the asset
/// database to [`AssetKind::Font`].
fn font_asset_picker(
&self,
ui: &mut egui::Ui,
id_salt: &(&str, Vec<usize>),
font_asset: &mut Option<AssetRef<oxide_engine::ui::Font>>,
) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label("font asset");
let Some(db) = &self.state.asset_db else {
ui.weak("(open a project)");
return;
};
let mut fonts: Vec<(AssetUid, String)> = db
.entries_of_kind(AssetKind::Font)
.map(|e| {
let leaf = e.path.rsplit('/').next().unwrap_or(&e.path).to_owned();
(e.uid, leaf)
})
.collect();
fonts.sort_by(|a, b| a.1.cmp(&b.1));
let current_uid = font_asset.and_then(|r| r.uid());
let selected_text = match current_uid {
Some(uid) => db
.relative_path(uid)
.map(|p| p.rsplit('/').next().unwrap_or(p).to_owned())
.unwrap_or_else(|| "(missing)".to_owned()),
None => "(inherit)".to_owned(),
};
egui::ComboBox::from_id_salt(id_salt)
.selected_text(selected_text)
.show_ui(ui, |ui| {
if ui
.selectable_label(current_uid.is_none(), "(inherit)")
.clicked()
{
*font_asset = None;
changed = true;
}
for (uid, leaf) in &fonts {
if ui
.selectable_label(current_uid == Some(*uid), leaf)
.clicked()
{
*font_asset = Some(AssetRef::new(*uid));
changed = true;
}
}
});
});
changed
}
/// Saves the open UI document as a `ui/` asset (RON). A never-saved
/// document is written to `ui/untitled.ron` and registered in the database.
fn save_ui_doc(&mut self) {
let Some(db) = &self.state.asset_db else {
log::warn!("cannot save UI document without an open project");
return;
};
let doc = self.state.ui_doc.as_ref().unwrap();
let rel = match doc.asset.and_then(|uid| db.relative_path(uid)) {
Some(rel) => rel.to_owned(),
None => "ui/untitled.ron".to_owned(),
};
let abs = db.assets_dir().join(&rel);
let Ok(text) = ron::ser::to_string_pretty(&doc.panel, ron::ser::PrettyConfig::default())
else {
log::warn!("failed to serialize UI document");
return;
};
if let Some(parent) = abs.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
log::warn!("could not create {}: {err}", parent.display());
return;
}
}
if let Err(err) = std::fs::write(&abs, text) {
log::warn!("could not write {}: {err}", abs.display());
return;
}
// Register a newly-saved document and remember its uid.
if let Some(db) = &mut self.state.asset_db {
let uid = db.register(&rel);
let _ = db.save();
if let Some(doc) = &mut self.state.ui_doc {
doc.asset = Some(uid);
doc.dirty = false;
}
}
}
fn console(&mut self, ui: &mut egui::Ui) {
let Some(buffer) = crate::console::log_buffer() else {
ui.weak("Console: logging is not initialised.");
return;
};
// Toolbar: line count + Clear.
ui.horizontal(|ui| {
let count = buffer.lock().map(|b| b.len()).unwrap_or(0);
ui.weak(format!("{count} line(s)"));
if ui.button("Clear").clicked() {
if let Ok(mut b) = buffer.lock() {
b.clear();
}
}
ui.weak("· script print/errors appear here");
});
// Command prompt: runs a shell command in the project root, streaming
// its output into this same panel.
ui.horizontal(|ui| {
ui.label("$");
let input = egui::TextEdit::singleline(self.terminal_input)
.hint_text("run a command…")
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Monospace);
let resp = ui.add(input);
let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if submitted && !self.terminal_input.trim().is_empty() {
let command = std::mem::take(self.terminal_input);
let cwd = self
.state
.asset_db
.as_ref()
.map(|db| db.root().to_path_buf())
.unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
});
crate::terminal::run(&command, &cwd);
resp.request_focus(); // keep typing without re-clicking
}
});
ui.separator();
// The log lines, newest pinned to the bottom, coloured by severity.
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
let Ok(b) = buffer.lock() else {
return;
};
if b.is_empty() {
ui.weak("(no output yet)");
return;
}
for line in b.iter() {
let color = level_color(ui, line.level);
// Compact one-line entry: [LEVEL target] message.
let head = format!("[{} {}]", line.level, line.target);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.colored_label(color, head);
ui.label(&line.message);
});
}
});
}
/// The interactive PTY terminal panel: a tab strip of sessions plus the
/// active session's screen grid; keystrokes route to the active terminal.
fn terminal_panel(&mut self, ui: &mut egui::Ui) {
// Auto-close sessions whose program has exited, then keep the active
// index in range — so typing `exit` (or the agent finishing) drops the
// tab instead of leaving a dead one behind.
self.terminals.retain_mut(|t| !t.has_exited());
if *self.active_terminal >= self.terminals.len() {
*self.active_terminal = self.terminals.len().saturating_sub(1);
}
// Tab strip: one selectable label per session (with a close button) and
// a "+" to open another shell.
let mut to_close: Option<usize> = None;
ui.horizontal(|ui| {
for idx in 0..self.terminals.len() {
let selected = idx == *self.active_terminal;
let label = format!("{} {}", self.terminals[idx].title, idx + 1);
if ui.selectable_label(selected, label).clicked() {
*self.active_terminal = idx;
}
if ui.small_button("x").on_hover_text("Close").clicked() {
to_close = Some(idx);
}
ui.separator();
}
if ui.button("+ Shell").clicked() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
self.launch_terminal("shell", &shell, &[]);
}
});
if let Some(idx) = to_close {
if idx < self.terminals.len() {
self.terminals.remove(idx); // Drop kills the child.
if *self.active_terminal >= self.terminals.len() {
*self.active_terminal = self.terminals.len().saturating_sub(1);
}
}
}
ui.separator();
if self.terminals.is_empty() {
ui.weak(
"No terminal. Click + Shell to start one — it runs in the project directory \
and supports interactive / full-screen programs (a shell, a REPL, vim, an \
AI-agent CLI like claude).",
);
return;
}
let term = &mut self.terminals[*self.active_terminal];
// A live session: keep repainting so streamed output shows promptly.
ui.ctx().request_repaint();
// Size the grid to the panel using the monospace cell metrics.
let font = egui::FontId::monospace(13.0);
let (cell_w, cell_h) = ui
.ctx()
.fonts_mut(|f| (f.glyph_width(&font, 'M'), f.row_height(&font)));
let avail = ui.available_size();
let cols = ((avail.x / cell_w).floor() as u16).max(1);
let rows = ((avail.y / cell_h).floor() as u16).max(1);
term.resize(rows, cols);
// Build the grid as a monospace LayoutJob and paint it into a focusable
// region so the panel can capture keystrokes.
let default_fg = ui.visuals().text_color();
let bg = ui.visuals().extreme_bg_color;
let job = term.with_screen(|screen| build_terminal_job(screen, &font, default_fg));
let galley = ui.painter().layout_job(job);
let (rect, response) = ui.allocate_exact_size(avail, egui::Sense::click());
if response.clicked() {
response.request_focus();
}
// Deliver Tab / arrows / Escape to the terminal instead of letting egui
// use them to move focus — a terminal program needs all of them (Tab
// completion, arrow navigation, vim's Esc).
ui.memory_mut(|m| {
m.set_focus_lock_filter(
response.id,
egui::EventFilter {
tab: true,
horizontal_arrows: true,
vertical_arrows: true,
escape: true,
},
)
});
ui.painter().rect_filled(rect, 0.0, bg);
ui.painter().galley(rect.min, galley, default_fg);
// Route input only while focused, so typing elsewhere isn't captured.
if response.has_focus() {
let events = ui.input(|i| i.events.clone());
for event in events {
match event {
egui::Event::Text(text) => term.send_input(text.as_bytes()),
egui::Event::Paste(text) => term.send_input(text.as_bytes()),
egui::Event::Key {
key,
pressed: true,
modifiers,
..
} => {
if let Some(bytes) = crate::pty::encode_key(key, modifiers) {
term.send_input(&bytes);
}
}
_ => {}
}
}
} else {
ui.weak("(click to focus and type)");
}
}
/// Spawns a terminal session running `program` (with `args`) in the project
/// directory, appends it as a new tab, and makes it active.
fn launch_terminal(&mut self, title: &str, program: &str, args: &[&str]) {
let cwd = self
.state
.asset_db
.as_ref()
.map(|db| db.root().to_path_buf())
.unwrap_or_else(|| {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
});
match crate::pty::PtyTerminal::spawn(title, program, args, &cwd, 24, 80) {
Ok(term) => {
self.terminals.push(term);
*self.active_terminal = self.terminals.len() - 1;
}
Err(err) => {
log::error!(target: "terminal", "failed to launch {program}: {err}");
}
}
}
fn custom_panel(&mut self, ui: &mut egui::Ui, name: &str) {
for panel in self.extensions.iter_panels_mut() {
if panel.name == name {
(panel.render)(ui);
return;
}
}
ui.weak(format!("Panel '{name}' is not registered."));
}
}
// ---- 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
/// is behind the camera (negative-w) so callers can skip painting that
/// vertex / handle.
fn project(
world: oxide_engine::math::Vec3,
view_proj: &Mat4,
tab_rect: egui::Rect,
) -> Option<egui::Pos2> {
let clip: Vec4 = *view_proj * world.extend(1.0);
if clip.w <= 0.0 {
return None;
}
let x_ndc = clip.x / clip.w;
let y_ndc = clip.y / clip.w;
let x = tab_rect.min.x + (x_ndc * 0.5 + 0.5) * tab_rect.width();
// Y flips: NDC up = screen down.
let y = tab_rect.min.y + (1.0 - (y_ndc * 0.5 + 0.5)) * tab_rect.height();
Some(egui::Pos2::new(x, y))
}
/// The end of the raycast-probe normal whisker: `point` plus `normal`
/// (re-normalized) scaled to `len`. Factored out so the geometry is unit-tested
/// and `paint_raycast_probe` only does the projection. A zero/degenerate normal
/// collapses to `point` (no whisker drawn).
fn probe_normal_tip(
point: oxide_engine::math::Vec3,
normal: oxide_engine::math::Vec3,
len: f32,
) -> oxide_engine::math::Vec3 {
point + normal.normalize_or_zero() * len
}
/// Two unit vectors orthogonal to `n` (and to each other), forming a
/// right-handed basis with `n`. Used to parameterize the rotate-axis
/// circle in 3D.
fn orthonormal_basis(
n: oxide_engine::math::Vec3,
) -> (oxide_engine::math::Vec3, oxide_engine::math::Vec3) {
use oxide_engine::math::Vec3;
// Pick a reference axis that isn't (near-) collinear with `n`.
let reference = if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
let u = n.cross(reference).normalize();
let v = n.cross(u);
(u, v)
}
/// Local-space line segments that outline a physics
/// [`Collider`](oxide_physics::Collider)'s shape, as `(start, end)` pairs in
/// the collider's local frame (the caller applies the world pose). Used by the
/// viewport collider gizmos (Stage 9 piece 8b). Conventions match the
/// simulation: capsule/cylinder axes are local `+Y`; sphere/capsule/cylinder
/// use `radius`, box uses `half_extents`, capsule/cylinder add `half_height`.
fn collider_wire_segments(
col: &oxide_physics::Collider,
) -> Vec<(oxide_engine::math::Vec3, oxide_engine::math::Vec3)> {
use oxide_engine::math::Vec3;
use oxide_physics::ColliderShape;
use std::f32::consts::{PI, TAU};
let mut segs: Vec<(Vec3, Vec3)> = Vec::new();
match col.shape {
ColliderShape::Box => {
let h = col.half_extents;
// 8 corners: indices 0..3 bottom (-Y), 4..7 top (+Y).
let c = [
Vec3::new(-h.x, -h.y, -h.z),
Vec3::new(h.x, -h.y, -h.z),
Vec3::new(h.x, -h.y, h.z),
Vec3::new(-h.x, -h.y, h.z),
Vec3::new(-h.x, h.y, -h.z),
Vec3::new(h.x, h.y, -h.z),
Vec3::new(h.x, h.y, h.z),
Vec3::new(-h.x, h.y, h.z),
];
const EDGES: [(usize, usize); 12] = [
(0, 1),
(1, 2),
(2, 3),
(3, 0), // bottom face
(4, 5),
(5, 6),
(6, 7),
(7, 4), // top face
(0, 4),
(1, 5),
(2, 6),
(3, 7), // verticals
];
for (a, b) in EDGES {
segs.push((c[a], c[b]));
}
}
ColliderShape::Sphere => {
let r = col.radius;
// Three great circles, one per coordinate plane.
push_arc(&mut segs, Vec3::ZERO, Vec3::X, Vec3::Y, r, (0.0, TAU), 24);
push_arc(&mut segs, Vec3::ZERO, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
push_arc(&mut segs, Vec3::ZERO, Vec3::Y, Vec3::Z, r, (0.0, TAU), 24);
}
ColliderShape::Capsule => {
let (r, hh) = (col.radius, col.half_height);
let (top, bot) = (Vec3::Y * hh, Vec3::Y * -hh);
// Seam rings where the hemispheres meet the cylinder.
push_arc(&mut segs, top, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
push_arc(&mut segs, bot, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
// Cylinder side lines on the four cardinal directions.
for d in [Vec3::X, -Vec3::X, Vec3::Z, -Vec3::Z] {
segs.push((bot + d * r, top + d * r));
}
// Hemisphere cap profiles (over the top, under the bottom).
push_arc(&mut segs, top, Vec3::X, Vec3::Y, r, (0.0, PI), 12);
push_arc(&mut segs, top, Vec3::Z, Vec3::Y, r, (0.0, PI), 12);
push_arc(&mut segs, bot, Vec3::X, Vec3::Y, r, (PI, TAU), 12);
push_arc(&mut segs, bot, Vec3::Z, Vec3::Y, r, (PI, TAU), 12);
}
ColliderShape::Cylinder => {
let (r, hh) = (col.radius, col.half_height);
let (top, bot) = (Vec3::Y * hh, Vec3::Y * -hh);
push_arc(&mut segs, top, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
push_arc(&mut segs, bot, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
for d in [Vec3::X, -Vec3::X, Vec3::Z, -Vec3::Z] {
segs.push((bot + d * r, top + d * r));
}
}
}
segs
}
/// Appends `segs` line segments tracing the arc of a circle centred at `c`,
/// radius `r`, in the plane spanned by unit vectors `u`/`v`, sweeping the angle
/// `range.0..range.1` (radians). A full `0..TAU` sweep traces a closed circle.
/// Helper for [`collider_wire_segments`].
fn push_arc(
out: &mut Vec<(oxide_engine::math::Vec3, oxide_engine::math::Vec3)>,
c: oxide_engine::math::Vec3,
u: oxide_engine::math::Vec3,
v: oxide_engine::math::Vec3,
r: f32,
range: (f32, f32),
segs: usize,
) {
let (a0, a1) = range;
let segs = segs.max(1);
let point = |t: f32| c + u * (r * t.cos()) + v * (r * t.sin());
let mut prev = point(a0);
for i in 1..=segs {
let t = a0 + (a1 - a0) * (i as f32) / (segs as f32);
let p = point(t);
out.push((prev, p));
prev = p;
}
}
/// The screen color for an axis handle. Brighter when the handle is the
/// active drag target so the user sees what they're holding.
fn axis_color(axis: gizmo::Axis3, engaged: bool) -> egui::Color32 {
use egui::Color32;
use gizmo::Axis3;
let (r, g, b): (u8, u8, u8) = match axis {
Axis3::X => (210, 60, 60),
Axis3::Y => (60, 200, 60),
Axis3::Z => (60, 110, 230),
};
if engaged {
// Lerp toward white for the engaged feedback.
Color32::from_rgb(
((r as u16 + 255) / 2) as u8,
((g as u16 + 255) / 2) as u8,
((b as u16 + 255) / 2) as u8,
)
} else {
Color32::from_rgb(r, g, b)
}
}
/// A display label for layer `index`: its registered name, or `"Layer N"` for
/// an unnamed slot. Shared by the single-select `Layer` widget and the
/// multi-select `LayerMask` field widget.
fn layer_label(index: u32, reg: &LayerRegistry) -> String {
reg.name(index)
.map(String::from)
.unwrap_or_else(|| format!("Layer {index}"))
}
/// A short summary of a [`LayerMask`] for a collapsed combo box: `"All"`,
/// `"None"`, or the comma-joined names of its set layers.
fn mask_summary(mask: LayerMask, reg: &LayerRegistry) -> String {
if mask == LayerMask::ALL {
return "All".to_string();
}
if mask.is_empty() {
return "None".to_string();
}
mask.iter()
.map(|i| layer_label(i, reg))
.collect::<Vec<_>>()
.join(", ")
}
/// Adds or removes group `group` from `entity`'s [`Tags`], creating the `Tags`
/// component on first add and dropping it again when the last group is removed
/// (so an entity in no groups carries no empty marker). Structural, so — like
/// component add/remove/disable — it bypasses the undo stack for now.
fn apply_group_membership(
world: &mut oxide_engine::hecs::World,
entity: Entity,
group: &str,
member: bool,
) {
use oxide_engine::layer::Tags;
if member {
// Update in place if the component exists; otherwise create it. The
// borrow guard must be released (the `if let` scope ends) before we can
// `insert_one`, so the existence check returns a bool first.
let updated = if let Ok(mut tags) = world.get::<&mut Tags>(entity) {
tags.insert(group);
true
} else {
false
};
if !updated {
let mut tags = Tags::new();
tags.insert(group);
let _ = world.insert_one(entity, tags);
}
} else {
let now_empty = if let Ok(mut tags) = world.get::<&mut Tags>(entity) {
tags.remove(group);
tags.is_empty()
} else {
false
};
if now_empty {
let _ = world.remove_one::<Tags>(entity);
}
}
}
fn vec3_drag(ui: &mut egui::Ui, label: &str, v: &mut Vec3, speed: f64) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label(label);
for (axis, value) in [("x", &mut v.x), ("y", &mut v.y), ("z", &mut v.z)] {
changed |= ui
.add(egui::DragValue::new(value).speed(speed).prefix(axis))
.changed();
}
});
changed
}
/// The node-baked component types — every entity inherently carries these,
/// they're single-instance, and the inspector renders them in a fixed
/// canonical order with no enable / remove / drag-reorder controls.
///
/// Maintainer's distinction: these are part of *being a node*, not a feature
/// you add or remove. Modular components (`MeshRenderer`, future colliders /
/// scripts / …) are everything else.
const ESSENTIAL_COMPONENTS: &[&str] = &["Node", "Transform", "Layer"];
fn is_essential_component(name: &str) -> bool {
ESSENTIAL_COMPONENTS.contains(&name)
}
/// Moves `dragged` to be just before (`place_before = true`) or just after
/// (`place_before = false`) `target` in the component order. Returns the new
/// order. Extracted from `reflected_components` so the index-juggling can be
/// unit-tested directly.
fn reorder_component_list(
mut order: Vec<&'static str>,
dragged: &'static str,
target: &'static str,
place_before: bool,
) -> Vec<&'static str> {
// Dropping a row onto itself is a no-op (the GUI excludes this anyway;
// guarding here keeps the helper composable for tests + future callers).
if dragged == target {
return order;
}
if let Some(from) = order.iter().position(|n| *n == dragged) {
order.remove(from);
}
let mut to = order
.iter()
.position(|n| *n == target)
.unwrap_or(order.len());
if !place_before {
to += 1;
}
to = to.min(order.len());
order.insert(to, dragged);
order
}
/// Toggles a component's disabled flag on `entity`'s [`DisabledComponents`],
/// creating the component lazily and removing it again when no entries remain
/// (so the entity doesn't carry an empty marker).
fn apply_component_disable(
world: &mut oxide_engine::hecs::World,
entity: Entity,
type_name: &str,
disabled: bool,
) {
let already = world.get::<&DisabledComponents>(entity).is_ok();
if already {
let mut d = world.get::<&mut DisabledComponents>(entity).unwrap();
d.set_disabled(type_name, disabled);
if d.is_empty() {
drop(d);
let _ = world.remove_one::<DisabledComponents>(entity);
}
} else if disabled {
let mut d = DisabledComponents::new();
d.set_disabled(type_name, true);
let _ = world.insert_one(entity, d);
}
}
/// Orders the components currently on an entity for the inspector:
///
/// 1. `Transform` always renders first (it's the canonical positional row, and
/// every entity has one).
/// 2. Then any components named in `saved` order, in their saved order —
/// skipping any that are no longer present.
/// 3. Then any present component that isn't in `saved` yet, appended in
/// whatever order the registry returned it (so components inserted outside
/// the inspector — e.g. by a script or `set_ron` — still show up).
///
/// Extracted so it's straightforwardly unit-testable; the inspector calls this
/// with the registry's `components_on` list (minus `Node`) and the entity's
/// saved order from [`EditorState::component_order`].
fn inspector_component_order(
present: &[&'static str],
saved: &[&'static str],
) -> Vec<&'static str> {
let mut remaining: Vec<&'static str> = present.to_vec();
let mut out: Vec<&'static str> = Vec::with_capacity(remaining.len());
if let Some(i) = remaining.iter().position(|n| *n == "Transform") {
out.push(remaining.remove(i));
}
for name in saved {
if let Some(i) = remaining.iter().position(|n| *n == *name) {
out.push(remaining.remove(i));
}
}
out.extend(remaining);
out
}
/// Where a hierarchy drag-and-drop will drop, relative to the row under the
/// pointer: above it (reorder before), below it (reorder after), onto it (nest
/// as a child), or past the last row (append to the root level).
#[derive(Clone, Copy)]
enum DropZone {
Before,
After,
Child,
RootEnd,
}
/// A flattened hierarchy row, snapshotted each frame so the tree can be drawn
/// without holding a borrow on the scene.
struct Row {
entity: Entity,
depth: usize,
name: String,
/// The node's own authored flag (what the checkbox shows/edits).
enabled: bool,
/// Whether the node is enabled *and* every ancestor is — drives the greyed
/// styling so a disabled parent visibly dims its whole subtree.
effective_enabled: bool,
}
fn snapshot(scene: &Scene) -> Vec<Row> {
let mut rows = Vec::with_capacity(scene.len());
for &root in scene.roots() {
snapshot_node(scene, root, 0, true, &mut rows);
}
rows
}
fn snapshot_node(
scene: &Scene,
entity: Entity,
depth: usize,
parent_effective: bool,
rows: &mut Vec<Row>,
) {
let enabled = scene.is_enabled(entity).unwrap_or(true);
let effective_enabled = parent_effective && enabled;
rows.push(Row {
entity,
depth,
name: scene.name(entity).unwrap_or_default(),
enabled,
effective_enabled,
});
for &child in scene.children(entity) {
snapshot_node(scene, child, depth + 1, effective_enabled, rows);
}
}
/// 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 {
let visuals = ui.visuals();
match level {
log::Level::Error => egui::Color32::from_rgb(0xFF, 0x6B, 0x6B),
log::Level::Warn => egui::Color32::from_rgb(0xFF, 0xC1, 0x07),
log::Level::Info => visuals.text_color(),
log::Level::Debug | log::Level::Trace => visuals.weak_text_color(),
}
}
/// Builds a monospace [`LayoutJob`](egui::text::LayoutJob) for a terminal
/// screen: one glyph per cell with its foreground colour and (non-default)
/// background, rows separated by newlines, and the cursor cell inverted. Column
/// alignment relies on the monospace font.
fn build_terminal_job(
screen: &vt100::Screen,
font: &egui::FontId,
default_fg: egui::Color32,
) -> egui::text::LayoutJob {
use egui::text::{LayoutJob, TextFormat};
let (rows, cols) = screen.size();
let (cur_row, cur_col) = screen.cursor_position();
let mut job = LayoutJob::default();
job.wrap.max_width = f32::INFINITY;
for row in 0..rows {
for col in 0..cols {
let cell = screen.cell(row, col);
let glyph = match cell.map(|c| c.contents()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => " ".to_string(),
};
let mut fg = cell
.map(|c| crate::pty::vt_color(c.fgcolor(), default_fg))
.unwrap_or(default_fg);
let mut bg = cell.and_then(|c| match c.bgcolor() {
vt100::Color::Default => None,
other => Some(crate::pty::vt_color(other, egui::Color32::TRANSPARENT)),
});
// Invert the cursor cell so the caret is visible.
if row == cur_row && col == cur_col {
bg = Some(default_fg);
fg = egui::Color32::BLACK;
}
let mut fmt = TextFormat {
font_id: font.clone(),
color: fg,
..Default::default()
};
if let Some(bg) = bg {
fmt.background = bg;
}
job.append(&glyph, 0.0, fmt);
}
job.append(
"\n",
0.0,
TextFormat {
font_id: font.clone(),
color: default_fg,
..Default::default()
},
);
}
job
}
/// 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 {
let kind = match &widget.kind {
WidgetKind::Leaf { .. } => "Leaf",
WidgetKind::Stack(s) => match s.direction {
oxide_engine::ui::StackDirection::Row => "Row",
oxide_engine::ui::StackDirection::Column => "Column",
},
WidgetKind::Grid(_) => "Grid",
WidgetKind::Anchor(_) => "Anchor",
};
let name = if widget.id.is_empty() {
kind.to_owned()
} else {
format!("{} ({kind})", widget.id.as_str())
};
if path.is_root() {
format!("{name} ⌂")
} else {
name
}
}
/// Renders the widget tree as selectable, indented rows. Sets `selection` to a
/// clicked node's path.
fn ui_widget_tree(
ui: &mut egui::Ui,
widget: &Widget,
path: WidgetPath,
selected: &WidgetPath,
selection: &mut Option<WidgetPath>,
) {
let label = widget_label(widget, &path);
if ui.selectable_label(*selected == path, label).clicked() {
*selection = Some(path.clone());
}
ui.indent(("oxide.uitree", path.0.clone()), |ui| {
for (i, child) in widget.children().iter().enumerate() {
ui_widget_tree(ui, child, path.child(i), selected, selection);
}
});
}
/// Converts an engine [`Color`] to an egui color.
fn color32(c: Color) -> egui::Color32 {
let to = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
egui::Color32::from_rgba_unmultiplied(to(c.r), to(c.g), to(c.b), to(c.a))
}
/// Draws a rectangle outline as four line segments (avoids depending on a
/// specific `rect_stroke` signature across egui versions).
fn stroke_rect(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
let tl = rect.left_top();
let tr = rect.right_top();
let br = rect.right_bottom();
let bl = rect.left_bottom();
for [a, b] in [[tl, tr], [tr, br], [br, bl], [bl, tl]] {
painter.line_segment([a, b], stroke);
}
}
/// A checkbox + color button for an `Option<Color>` visual field. Returns
/// whether it changed.
fn optional_color(ui: &mut egui::Ui, label: &str, slot: &mut Option<Color>) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
let mut on = slot.is_some();
if ui.checkbox(&mut on, label).changed() {
*slot = on.then_some(Color::WHITE);
changed = true;
}
if let Some(c) = slot {
let mut rgba = [c.r, c.g, c.b, c.a];
if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
*c = Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
changed = true;
}
}
});
changed
}
/// A `Sizing` editor: a mode dropdown plus a value drag for `Fixed`/`Grow`.
/// Returns whether it changed.
fn sizing_editor(ui: &mut egui::Ui, label: &str, sizing: &mut UiSizing, salt: usize) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label(label);
let mode = match sizing {
UiSizing::Fixed(_) => "Fixed",
UiSizing::Grow(_) => "Grow",
UiSizing::FitContent => "FitContent",
};
egui::ComboBox::from_id_salt(("oxide.uicanvas.sizing", label, salt))
.selected_text(mode)
.show_ui(ui, |ui| {
if ui.selectable_label(mode == "Fixed", "Fixed").clicked() && mode != "Fixed" {
*sizing = UiSizing::Fixed(64.0);
changed = true;
}
if ui.selectable_label(mode == "Grow", "Grow").clicked() && mode != "Grow" {
*sizing = UiSizing::Grow(1.0);
changed = true;
}
if ui
.selectable_label(mode == "FitContent", "FitContent")
.clicked()
&& mode != "FitContent"
{
*sizing = UiSizing::FitContent;
changed = true;
}
});
match sizing {
UiSizing::Fixed(v) => {
if ui
.add(egui::DragValue::new(v).range(0.0..=4096.0))
.changed()
{
changed = true;
}
}
UiSizing::Grow(w) => {
if ui
.add(egui::DragValue::new(w).speed(0.1).range(0.0..=100.0))
.changed()
{
changed = true;
}
}
UiSizing::FitContent => {}
}
});
changed
}
/// Type-specific parameters for the selected widget's [`WidgetKind`] — this is
/// what distinguishes a Grid from a Stack from a Leaf in the inspector. Returns
/// whether anything changed.
fn kind_properties(ui: &mut egui::Ui, kind: &mut WidgetKind, salt: &WidgetPath) -> bool {
let mut changed = false;
match kind {
WidgetKind::Leaf { intrinsic } => {
ui.horizontal(|ui| {
ui.label("intrinsic size");
changed |= ui
.add(
egui::DragValue::new(&mut intrinsic.x)
.prefix("w ")
.range(0.0..=4096.0),
)
.changed();
changed |= ui
.add(
egui::DragValue::new(&mut intrinsic.y)
.prefix("h ")
.range(0.0..=4096.0),
)
.changed();
});
}
WidgetKind::Stack(s) => {
ui.horizontal(|ui| {
ui.label("direction");
let text = match s.direction {
UiStackDirection::Row => "Row",
UiStackDirection::Column => "Column",
};
egui::ComboBox::from_id_salt(("oxide.uicanvas.dir", salt.0.clone()))
.selected_text(text)
.show_ui(ui, |ui| {
for (label, dir) in [
("Row", UiStackDirection::Row),
("Column", UiStackDirection::Column),
] {
if ui.selectable_label(s.direction == dir, label).clicked()
&& s.direction != dir
{
s.direction = dir;
changed = true;
}
}
});
});
ui.horizontal(|ui| {
ui.label("gap");
changed |= ui
.add(egui::DragValue::new(&mut s.gap).range(0.0..=512.0))
.changed();
});
changed |= align_combo(ui, "main align", &mut s.main_align, 2);
}
WidgetKind::Grid(g) => {
ui.horizontal(|ui| {
ui.label("cols");
changed |= ui
.add(egui::DragValue::new(&mut g.cols).range(1..=64))
.changed();
ui.label("rows");
changed |= ui
.add(egui::DragValue::new(&mut g.rows).range(1..=64))
.changed();
});
ui.horizontal(|ui| {
ui.label("gap");
changed |= ui
.add(
egui::DragValue::new(&mut g.gap.x)
.prefix("x ")
.range(0.0..=512.0),
)
.changed();
changed |= ui
.add(
egui::DragValue::new(&mut g.gap.y)
.prefix("y ")
.range(0.0..=512.0),
)
.changed();
});
}
WidgetKind::Anchor(_) => {
ui.weak("Anchor container: each child is placed by its own anchor.");
}
}
changed
}
/// A three-way [`Align`](oxide_engine::ui::Align) dropdown (Start/Center/End).
fn align_combo(ui: &mut egui::Ui, label: &str, align: &mut UiAlign, salt: usize) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label(label);
let text = match align {
UiAlign::Start => "Start",
UiAlign::Center => "Center",
UiAlign::End => "End",
};
egui::ComboBox::from_id_salt(("oxide.uicanvas.align", label, salt))
.selected_text(text)
.show_ui(ui, |ui| {
for (name, value) in [
("Start", UiAlign::Start),
("Center", UiAlign::Center),
("End", UiAlign::End),
] {
if ui.selectable_label(*align == value, name).clicked() && *align != value {
*align = value;
changed = true;
}
}
});
});
changed
}
/// Editor for the four sides of an [`Insets`](oxide_engine::ui::Insets) value.
fn insets_editor(ui: &mut egui::Ui, label: &str, insets: &mut UiInsets, salt: usize) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label(label);
for (prefix, value) in [
("l", &mut insets.left),
("r", &mut insets.right),
("t", &mut insets.top),
("b", &mut insets.bottom),
] {
let _ = salt;
changed |= ui
.add(
egui::DragValue::new(value)
.prefix(prefix)
.range(0.0..=512.0),
)
.changed();
}
});
changed
}
/// A dropdown of the standard [`Anchor`](oxide_engine::ui::Anchor) presets
/// (Fill / corners / edges / center). Shows "Custom" if the current value
/// matches no preset. Anchor placement only takes effect under an Anchor parent.
fn anchor_combo(ui: &mut egui::Ui, anchor: &mut UiAnchor, salt: &WidgetPath) -> bool {
let center = UiAnchor {
min: Vec2::splat(0.5),
max: Vec2::splat(0.5),
offset_min: Vec2::ZERO,
offset_max: Vec2::ZERO,
};
let presets: [(&str, UiAnchor); 10] = [
("Fill", UiAnchor::FILL),
("Top-Left", UiAnchor::TOP_LEFT),
("Top", UiAnchor::TOP),
("Top-Right", UiAnchor::TOP_RIGHT),
("Left", UiAnchor::LEFT),
("Center", center),
("Right", UiAnchor::RIGHT),
("Bottom-Left", UiAnchor::BOTTOM_LEFT),
("Bottom", UiAnchor::BOTTOM),
("Bottom-Right", UiAnchor::BOTTOM_RIGHT),
];
let current = presets
.iter()
.find(|(_, a)| a == anchor)
.map(|(n, _)| *n)
.unwrap_or("Custom");
let mut changed = false;
ui.horizontal(|ui| {
ui.label("anchor");
egui::ComboBox::from_id_salt(("oxide.uicanvas.anchor", salt.0.clone()))
.selected_text(current)
.show_ui(ui, |ui| {
for (name, value) in presets {
if ui.selectable_label(current == name, name).clicked() {
*anchor = value;
changed = true;
}
}
});
});
changed
}
/// Paints a scaled-to-fit preview of the panel into the available space using
/// egui's painter. Backgrounds/borders are drawn faithfully; text uses egui's
/// font (engine-font-accurate preview via the real `UiOverlayPass` is a noted
/// follow-up).
fn draw_ui_preview(ui: &mut egui::Ui, panel: &UiPanel) {
let avail_w = ui.available_width().max(16.0);
let aspect = if panel.pixel_size.x > 0.0 {
panel.pixel_size.y / panel.pixel_size.x
} else {
0.5625
};
let height = (avail_w * aspect).clamp(120.0, 360.0);
let (resp, painter) = ui.allocate_painter(egui::vec2(avail_w, height), egui::Sense::hover());
let area = resp.rect;
if panel.pixel_size.x <= 0.0 || panel.pixel_size.y <= 0.0 {
return;
}
let scale = (area.width() / panel.pixel_size.x).min(area.height() / panel.pixel_size.y);
let draw = egui::vec2(panel.pixel_size.x * scale, panel.pixel_size.y * scale);
let origin = egui::pos2(
area.center().x - draw.x / 2.0,
area.center().y - draw.y / 2.0,
);
// Panel bounds.
stroke_rect(
&painter,
egui::Rect::from_min_size(origin, draw),
egui::Stroke::new(1.0_f32, egui::Color32::DARK_GRAY),
);
let theme = UiTheme::new();
let viewport = Rect::from_min_size(Vec2::ZERO, panel.pixel_size);
let tree = ui_layout(&panel.root, viewport, 1.0);
draw_preview_node(&painter, origin, scale, &theme, &panel.root, &tree, 0);
}
/// Recursively paints one widget (background, border, text) and its children
/// into the preview, mirroring `oxide_engine::ui::paint`'s structure.
fn draw_preview_node(
painter: &egui::Painter,
origin: egui::Pos2,
scale: f32,
theme: &UiTheme,
widget: &Widget,
tree: &oxide_engine::ui::LayoutTree,
index: usize,
) {
let node = &tree.nodes()[index];
let resolved = widget.resolve_visual(theme);
let to_screen = |r: Rect| {
egui::Rect::from_min_max(
egui::pos2(origin.x + r.min.x * scale, origin.y + r.min.y * scale),
egui::pos2(origin.x + r.max.x * scale, origin.y + r.max.y * scale),
)
};
if let Some(bg) = resolved.background {
if !node.rect.is_empty() {
painter.rect_filled(to_screen(node.rect), 0.0, color32(bg));
}
}
if let Some(border) = resolved.border {
if border.width > 0.0 {
stroke_rect(
painter,
to_screen(node.rect),
egui::Stroke::new((border.width * scale).max(1.0), color32(border.color)),
);
}
}
if let Some(text) = &widget.text {
let color = resolved
.foreground
.map(color32)
.unwrap_or(egui::Color32::WHITE);
let size = (resolved.font_size.unwrap_or(14.0) * scale).max(4.0);
let rect = to_screen(node.content_rect);
painter.text(
rect.left_top(),
egui::Align2::LEFT_TOP,
text,
egui::FontId::proportional(size),
color,
);
}
for (child, &child_index) in widget.children().iter().zip(node.children.iter()) {
draw_preview_node(
painter,
origin,
scale,
theme,
child,
tree,
child_index as usize,
);
}
}
fn show_project_tree(ui: &mut egui::Ui, label: &str, root: PathBuf) {
ui.collapsing(label, |ui| {
let Ok(entries) = std::fs::read_dir(&root) else {
ui.weak("(unreadable)");
return;
};
let mut names: Vec<String> = entries
.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
.collect();
names.sort();
if names.is_empty() {
ui.weak("(empty)");
}
for name in names {
ui.label(name);
}
});
}
/// A small starter scene so the dock has something to show on launch.
fn starter_scene() -> Scene {
let mut scene = Scene::new();
let world = scene.spawn("world", Transform::IDENTITY);
let player = scene.spawn_child(
world,
"player",
Transform::from_translation(Vec3::new(0.0, 1.0, 0.0)),
);
scene.spawn_child(
player,
"camera",
Transform::from_translation(Vec3::new(0.0, 1.6, 0.0)),
);
let level = scene.spawn_child(world, "level", Transform::IDENTITY);
let ground = scene.spawn_child(
level,
"ground",
Transform::from_scale(Vec3::new(20.0, 1.0, 20.0)),
);
attach_mesh(
&mut scene,
ground,
PrimitiveShape::Plane,
Material::diffuse(Color::rgb(0.28, 0.30, 0.33)),
);
let crate_e = scene.spawn_child(
level,
"prop_crate",
Transform::from_translation(Vec3::new(2.0, 0.5, 1.0)),
);
attach_mesh(
&mut scene,
crate_e,
PrimitiveShape::Cube,
Material::diffuse(Color::rgb(0.80, 0.40, 0.15)),
);
let ball = scene.spawn_child(
level,
"ball",
Transform::from_trs(Vec3::new(-1.5, 0.6, 0.0), Quat::IDENTITY, Vec3::splat(0.6)),
);
attach_mesh(
&mut scene,
ball,
PrimitiveShape::Sphere,
Material::metal(Color::rgb(0.55, 0.65, 0.95), 0.3),
);
scene
}
fn attach_mesh(scene: &mut Scene, entity: Entity, shape: PrimitiveShape, material: Material) {
let _ = scene
.world_mut()
.insert_one(entity, MeshRenderer::with_material(shape, material));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::SetTransformCmd;
#[test]
fn shell_starts_with_open_project_none() {
let shell = Shell::new();
assert!(shell.state.project.is_none());
assert!(!shell.commands.can_undo());
// Default layout includes the five built-in panel kinds.
let titles: Vec<&str> = shell.dock.iter_all_tabs().map(|(_, t)| t.title()).collect();
assert!(titles.contains(&"Hierarchy"));
assert!(titles.contains(&"Inspector"));
assert!(titles.contains(&"Viewport"));
assert!(titles.contains(&"Project"));
assert!(titles.contains(&"Console"));
}
#[test]
fn inspector_order_pins_transform_first_then_saved_order_then_appends_rest() {
// Transform always first, even when not first in `present`.
let present = ["MeshRenderer", "Timer", "Transform"];
let saved = ["Timer", "MeshRenderer"];
assert_eq!(
inspector_component_order(&present, &saved),
vec!["Transform", "Timer", "MeshRenderer"]
);
// A saved entry that's no longer present is silently skipped.
let saved_stale = ["Timer", "MeshRenderer", "Ghost"];
assert_eq!(
inspector_component_order(&present, &saved_stale),
vec!["Transform", "Timer", "MeshRenderer"]
);
// A present component not in `saved` (e.g. inserted by a script via
// set_ron) is appended after the saved order.
let present_extra = ["Transform", "Timer", "MeshRenderer", "RigidBody"];
let saved2 = ["Timer", "MeshRenderer"];
assert_eq!(
inspector_component_order(&present_extra, &saved2),
vec!["Transform", "Timer", "MeshRenderer", "RigidBody"]
);
// Empty saved + only Transform present.
assert_eq!(
inspector_component_order(&["Transform"], &[]),
vec!["Transform"]
);
}
#[test]
fn duplicate_preserves_inspector_component_order() {
// Source has Transform + MeshRenderer with a specific saved order;
// Duplicate carries that order over so the copy shows components in
// the same arrangement instead of reverting to registry-default.
let mut shell = Shell::from_state(EditorState::new());
let src = shell.state.scene.spawn("src", Transform::IDENTITY);
shell
.state
.scene
.world_mut()
.insert_one(src, MeshRenderer::new(PrimitiveShape::Cube))
.unwrap();
shell
.state
.component_order
.insert(src, vec!["MeshRenderer"]);
shell.apply(PendingAction::Duplicate(src));
let new = shell.state.selected.unwrap();
assert_eq!(
shell.state.component_order.get(&new).map(Vec::as_slice),
Some(["MeshRenderer"].as_slice())
);
}
#[test]
fn essential_component_classifier_pins_node_transform_layers() {
for &name in &["Node", "Transform", "Layer"] {
assert!(is_essential_component(name), "{name} should be essential");
}
// Modular components — every real one needs to be addable / removable
// / reorderable.
for &name in &["MeshRenderer", "RigidBody", "Tags", "MyScript"] {
assert!(
!is_essential_component(name),
"{name} must not be classified essential"
);
}
}
#[test]
fn layer_label_uses_registry_name_or_indexed_fallback() {
let mut reg = LayerRegistry::new();
reg.set(2, "Player");
assert_eq!(layer_label(0, &reg), "Default"); // seeded by LayerRegistry
assert_eq!(layer_label(2, &reg), "Player");
assert_eq!(layer_label(7, &reg), "Layer 7"); // unnamed → indexed
}
#[test]
fn mask_summary_reports_all_none_and_named_layers() {
let mut reg = LayerRegistry::new();
reg.set(1, "UI");
reg.set(2, "Player");
assert_eq!(mask_summary(LayerMask::ALL, &reg), "All");
assert_eq!(mask_summary(LayerMask::NONE, &reg), "None");
let mask = LayerMask::NONE.with(1).with(2).with(5);
assert_eq!(mask_summary(mask, &reg), "UI, Player, Layer 5");
}
#[test]
fn group_membership_creates_toggles_and_drops_tags() {
use oxide_engine::layer::Tags;
let mut scene = Scene::new();
let e = scene.spawn("e", Transform::IDENTITY);
// Tags is lazily attached — absent until the first group is added.
assert!(scene.world().get::<&Tags>(e).is_err());
apply_group_membership(scene.world_mut(), e, "Enemies", true);
apply_group_membership(scene.world_mut(), e, "Pickups", true);
{
let tags = scene.world().get::<&Tags>(e).unwrap();
assert!(tags.contains("Enemies"));
assert!(tags.contains("Pickups"));
}
// Removing one keeps the component; removing the last drops it so an
// entity in no groups carries no empty marker.
apply_group_membership(scene.world_mut(), e, "Enemies", false);
assert!(scene.world().get::<&Tags>(e).unwrap().contains("Pickups"));
apply_group_membership(scene.world_mut(), e, "Pickups", false);
assert!(scene.world().get::<&Tags>(e).is_err());
}
#[test]
fn editor_seeds_default_layers_and_several_addable_components() {
let state = EditorState::new();
// The common starter layer names exist (besides "Default").
assert_eq!(state.layer_registry.name(1), Some("UI"));
assert_eq!(state.layer_registry.name(2), Some("Player"));
assert_eq!(state.layer_registry.name(3), Some("World"));
// Several distinct modular components are addable, so more than one can
// be attached to a single node and drag-reordered.
let addable: Vec<_> = state.registry.addable_names().collect();
assert!(addable.contains(&"MeshRenderer"));
assert!(addable.contains(&"Camera"));
assert!(addable.contains(&"DirectionalLight"));
}
#[test]
fn editor_seeds_builtin_prefabs() {
let state = EditorState::new();
let names: Vec<_> = state.prefab_registry.names().collect();
for expected in [
"Empty",
"Cube",
"Sphere",
"Plane",
"Camera",
"Directional Light",
] {
assert!(names.contains(&expected), "{expected} prefab missing");
}
// Built-in prefabs reference only registered component types.
assert!(state
.prefab_registry
.unknown_specs("Cube", &state.registry)
.is_empty());
assert!(state
.prefab_registry
.unknown_specs("Directional Light", &state.registry)
.is_empty());
}
#[test]
fn add_root_prefab_spawns_entity_with_components_and_selects_it() {
let mut shell = Shell::from_state(EditorState::new());
shell.apply(PendingAction::AddRootPrefab("Cube".to_string()));
let e = shell.state.selected.expect("spawned entity is selected");
// Carries the prefab's MeshRenderer and takes the prefab's node name.
assert!(shell
.state
.registry
.has(shell.state.scene.world(), e, "MeshRenderer")
.unwrap());
assert_eq!(shell.state.scene.name(e).as_deref(), Some("Cube"));
}
#[test]
fn add_child_prefab_parents_under_target() {
let mut shell = Shell::from_state(EditorState::new());
shell.apply(PendingAction::AddRootPrefab("Empty".to_string()));
let parent = shell.state.selected.unwrap();
shell.apply(PendingAction::AddChildPrefab(parent, "Camera".to_string()));
let child = shell.state.selected.unwrap();
assert_eq!(shell.state.scene.parent(child), Some(parent));
assert!(shell
.state
.registry
.has(shell.state.scene.world(), child, "Camera")
.unwrap());
}
#[test]
fn reorder_component_list_handles_all_directions() {
// Move B before A (forward → backward).
assert_eq!(
reorder_component_list(vec!["A", "B", "C"], "B", "A", true),
vec!["B", "A", "C"]
);
// Move A after C (backward → forward, three-way reshuffle).
assert_eq!(
reorder_component_list(vec!["A", "B", "C"], "A", "C", false),
vec!["B", "C", "A"]
);
// Move B after A is a no-op (it's already there).
assert_eq!(
reorder_component_list(vec!["A", "B", "C"], "B", "A", false),
vec!["A", "B", "C"]
);
// Move B before B (dropping on itself, sanity): same list returns.
assert_eq!(
reorder_component_list(vec!["A", "B", "C"], "B", "B", true),
vec!["A", "B", "C"]
);
}
#[test]
fn apply_component_disable_creates_and_clears_marker_component() {
let mut shell = Shell::from_state(EditorState::new());
let e = shell.state.scene.spawn("e", Transform::IDENTITY);
let world = shell.state.scene.world_mut();
// Disabling adds the marker component with one entry.
apply_component_disable(world, e, "MeshRenderer", true);
let d = world.get::<&DisabledComponents>(e).unwrap();
assert!(d.is_disabled("MeshRenderer"));
drop(d);
// Re-enabling removes the only entry → the marker is dropped entirely
// so the entity isn't carrying empty metadata.
apply_component_disable(world, e, "MeshRenderer", false);
assert!(world.get::<&DisabledComponents>(e).is_err());
}
#[test]
fn duplicate_copies_per_component_disable_set() {
let mut shell = Shell::from_state(EditorState::new());
let src = shell.state.scene.spawn("src", Transform::IDENTITY);
shell
.state
.scene
.world_mut()
.insert_one(src, MeshRenderer::new(PrimitiveShape::Cube))
.unwrap();
apply_component_disable(shell.state.scene.world_mut(), src, "MeshRenderer", true);
shell.apply(PendingAction::Duplicate(src));
let new = shell.state.selected.unwrap();
assert!(shell.state.scene.is_component_disabled(new, "MeshRenderer"));
}
#[test]
fn delete_clears_inspector_component_order() {
let mut shell = Shell::from_state(EditorState::new());
let e = shell.state.scene.spawn("e", Transform::IDENTITY);
shell.state.component_order.insert(e, vec!["MeshRenderer"]);
shell.apply(PendingAction::Delete(e));
assert!(!shell.state.component_order.contains_key(&e));
}
#[test]
fn duplicate_copies_registered_components_like_mesh_renderer() {
// Regression: until MeshRenderer was registered in the editor's
// registry, Duplicate produced a "node copy" with no mesh.
let mut shell = Shell::from_state(EditorState::new());
let cube = shell.state.scene.spawn("cube", Transform::IDENTITY);
shell
.state
.scene
.world_mut()
.insert_one(cube, MeshRenderer::new(PrimitiveShape::Sphere))
.unwrap();
shell.apply(PendingAction::Duplicate(cube));
let new = shell.state.selected.expect("duplicate selects the copy");
assert_eq!(shell.state.scene.name(new).as_deref(), Some("cube copy"));
let mr = shell
.state
.scene
.get::<MeshRenderer>(new)
.expect("MeshRenderer copied");
assert_eq!(mr.shape, PrimitiveShape::Sphere);
}
#[test]
fn duplicate_clones_an_entity_as_a_sibling_with_components() {
let mut shell = Shell::from_state(EditorState::new());
let parent = shell.state.scene.spawn("parent", Transform::IDENTITY);
let child = shell.state.scene.spawn_child(
parent,
"child",
Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
);
let before = shell.state.scene.len();
shell.apply(PendingAction::Duplicate(child));
// One new entity, selected, named "<name> copy", same parent + transform.
assert_eq!(shell.state.scene.len(), before + 1);
let new = shell.state.selected.expect("duplicate selects the copy");
assert_ne!(new, child);
assert_eq!(shell.state.scene.name(new).as_deref(), Some("child copy"));
assert_eq!(shell.state.scene.parent(new), Some(parent));
assert_eq!(
shell.state.scene.local_transform(new).unwrap().translation,
Vec3::new(1.0, 2.0, 3.0)
);
}
#[test]
fn ctrl_s_without_project_is_a_no_op_but_handled() {
let mut shell = Shell::new();
// Handled = true; the shortcut belongs to the shell even when there's
// nothing to save (avoids the OS bell + lets us show a status hint).
assert!(shell.try_consume_shortcut(true, false, Some('s')));
assert!(shell.state.project.is_none());
}
#[test]
fn ctrl_comma_toggles_preferences() {
let mut shell = Shell::new();
assert!(!shell.show_preferences);
assert!(shell.try_consume_shortcut(true, false, Some(',')));
assert!(shell.show_preferences);
assert!(shell.try_consume_shortcut(true, false, Some(',')));
assert!(!shell.show_preferences);
}
#[test]
fn take_quit_request_consumes_the_flag_once() {
// The host runner polls this each frame; once observed the flag
// resets, so an accidental double-poll wouldn't exit twice.
let mut shell = Shell::new();
assert!(!shell.take_quit_request());
shell.quit_requested = true;
assert!(shell.take_quit_request());
assert!(!shell.take_quit_request());
}
#[test]
fn ctrl_z_y_drive_undo_redo() {
let mut shell = Shell::new();
let e = shell.state.scene.spawn("x", Transform::IDENTITY);
let cmd = SetTransformCmd::new(
&shell.state,
e,
Transform::from_translation(Vec3::splat(1.0)),
)
.unwrap();
shell.commands.push(cmd, &mut shell.state);
assert!(shell.commands.can_undo());
assert!(shell.try_consume_shortcut(true, false, Some('z')));
assert!(!shell.commands.can_undo());
assert!(shell.commands.can_redo());
assert!(shell.try_consume_shortcut(true, false, Some('y')));
assert!(shell.commands.can_undo());
}
#[test]
fn open_project_records_recent_and_clears_undo() {
// Round-trip through a tempdir: create_project then close_project
// exercises the watcher attach/detach path without GUI.
let mut root = std::env::temp_dir();
root.push(format!("oxide_shell_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut shell = Shell::new();
let e = shell.state.scene.spawn("x", Transform::IDENTITY);
let cmd =
SetTransformCmd::new(&shell.state, e, Transform::from_translation(Vec3::ONE)).unwrap();
shell.commands.push(cmd, &mut shell.state);
assert!(shell.commands.can_undo());
shell.create_project(&root, "shell-test").expect("create");
assert!(shell.state.project.is_some());
assert_eq!(shell.state.recent.entries().len(), 1);
// Project open clears history — a half-done edit shouldn't be undoable
// across project boundaries.
assert!(!shell.commands.can_undo());
shell.close_project();
assert!(shell.state.project.is_none());
std::fs::remove_dir_all(&root).ok();
}
// --- Piece 5: input-bindings capture state machine --------------------
use oxide_engine::input::ActionOverrides;
use oxide_engine::winit::keyboard::KeyCode as KC;
#[test]
fn shell_registers_default_bindings_and_settings_section() {
let shell = Shell::new();
assert!(shell
.state
.actions
.has(crate::bindings::action::TOGGLE_FLYTHROUGH));
assert!(shell
.state
.actions
.has_axis(crate::bindings::action::MOVE_RIGHT));
assert!(
shell
.state
.settings
.is_registered(crate::bindings::SETTINGS_SECTION),
"input.bindings settings section must be auto-registered by EditorState"
);
}
#[test]
fn capture_replaces_button_binding_and_marks_dirty() {
let mut shell = Shell::new();
// Sanity: F is the default toggle binding.
assert_eq!(
shell
.state
.actions
.bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
&[Binding::Key(KC::KeyF)]
);
assert!(!shell.bindings_dirty);
shell.begin_capture(CaptureTarget::Button {
action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(),
slot: BindingSlot::Replace(0),
});
assert!(shell.capture_active());
// First frame after begin: no key pressed → still waiting.
let mut input = InputState::new();
assert!(!shell.try_complete_capture(&input));
assert!(shell.capture_active());
// Next frame: user presses Tab.
input.press_key(KC::Tab);
assert!(shell.try_complete_capture(&input));
assert!(!shell.capture_active());
assert_eq!(
shell
.state
.actions
.bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
&[Binding::Key(KC::Tab)]
);
assert!(shell.bindings_dirty, "capture must flip the dirty flag");
}
#[test]
fn escape_cancels_capture_without_binding() {
let mut shell = Shell::new();
shell.begin_capture(CaptureTarget::Button {
action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(),
slot: BindingSlot::Replace(0),
});
let mut input = InputState::new();
input.press_key(KC::Escape);
assert!(shell.try_complete_capture(&input));
assert!(!shell.capture_active());
// Original binding intact.
assert_eq!(
shell
.state
.actions
.bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
&[Binding::Key(KC::KeyF)]
);
assert!(!shell.bindings_dirty);
}
#[test]
fn append_capture_adds_binding_to_slot() {
let mut shell = Shell::new();
shell.begin_capture(CaptureTarget::Button {
action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(),
slot: BindingSlot::Append,
});
let mut input = InputState::new();
input.press_key(KC::Tab);
shell.try_complete_capture(&input);
assert_eq!(
shell
.state
.actions
.bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
&[Binding::Key(KC::KeyF), Binding::Key(KC::Tab)]
);
}
#[test]
fn axis_capture_routes_to_the_correct_direction() {
let mut shell = Shell::new();
shell.begin_capture(CaptureTarget::Axis {
action: crate::bindings::action::MOVE_RIGHT.to_string(),
side: AxisSide::Positive,
slot: BindingSlot::Append,
});
let mut input = InputState::new();
input.press_key(KC::ArrowRight);
shell.try_complete_capture(&input);
let axis = shell
.state
.actions
.axis_bindings(crate::bindings::action::MOVE_RIGHT)
.unwrap();
assert!(axis.positive.contains(&Binding::Key(KC::ArrowRight)));
// Negative direction untouched.
assert_eq!(axis.negative, vec![Binding::Key(KC::KeyA)]);
}
#[test]
fn restore_all_defaults_undoes_remap_and_marks_dirty() {
let mut shell = Shell::new();
shell.state.actions.set_bindings(
crate::bindings::action::TOGGLE_FLYTHROUGH,
vec![Binding::Key(KC::Tab)],
);
shell.restore_default_bindings();
assert_eq!(
shell
.state
.actions
.bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
&[Binding::Key(KC::KeyF)]
);
assert!(shell.take_bindings_dirty());
assert!(!shell.take_bindings_dirty(), "dirty flag is consume-once");
}
#[test]
fn capture_keeps_overrides_settings_section_in_sync() {
let mut shell = Shell::new();
shell.begin_capture(CaptureTarget::Button {
action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(),
slot: BindingSlot::Replace(0),
});
let mut input = InputState::new();
input.press_key(KC::Tab);
shell.try_complete_capture(&input);
// The settings section now reflects the remap, so a Settings::export
// immediately after a capture captures the user's choice.
let overrides = shell
.state
.settings
.get::<ActionOverrides>(crate::bindings::SETTINGS_SECTION)
.expect("overrides section is registered");
assert_eq!(
overrides.get(crate::bindings::action::TOGGLE_FLYTHROUGH),
&[Binding::Key(KC::Tab)]
);
}
#[test]
fn play_controls_drive_play_state_and_clear_undo() {
let mut shell = Shell::from_state(EditorState::new());
let e = shell.state.scene.spawn("thing", Transform::IDENTITY);
// Put something on the undo stack so we can prove play clears it.
let cmd = RenameCmd::new(&shell.state, e, "renamed".to_string());
shell.push_command(cmd);
assert!(shell.commands.can_undo());
shell.play();
assert_eq!(shell.state.play, PlayState::Playing);
assert!(shell.state.play_snapshot.is_some());
// Entering play wipes the undo history (play edits never leak to edit).
assert!(!shell.commands.can_undo());
// Step is ignored while playing.
shell.request_step();
assert!(!shell.take_step_request());
// Pause, then Step queues exactly one request.
shell.toggle_pause();
assert_eq!(shell.state.play, PlayState::Paused);
shell.request_step();
assert!(shell.take_step_request());
assert!(!shell.take_step_request(), "step request is one-shot");
// The Play/Resume button resumes from pause (regression: it used to call
// play(), which no-ops while in play, leaving it stuck on Paused).
shell.play_or_resume();
assert_eq!(shell.state.play, PlayState::Playing);
// While playing it's a no-op (the button is disabled in the UI too).
shell.play_or_resume();
assert_eq!(shell.state.play, PlayState::Playing);
}
#[test]
fn stop_restores_the_scene_and_returns_to_editing() {
let mut shell = Shell::from_state(EditorState::new());
let e = shell.state.scene.spawn("thing", Transform::IDENTITY);
let before = shell.state.scene.to_ron().unwrap();
shell.play();
// Mutate as a tick would, then Stop.
shell
.state
.scene
.set_local_transform(e, Transform::from_translation(Vec3::new(3.0, 0.0, 0.0)));
shell.stop();
assert_eq!(shell.state.play, PlayState::Editing);
assert!(shell.state.play_snapshot.is_none());
assert_eq!(shell.state.scene.to_ron().unwrap(), before);
}
#[test]
fn ctrl_p_plays_then_toggles_pause() {
let mut shell = Shell::from_state(EditorState::new());
shell.state.scene.spawn("thing", Transform::IDENTITY);
// Ctrl+P from editing → Play.
assert!(shell.try_consume_shortcut(true, false, Some('p')));
assert_eq!(shell.state.play, PlayState::Playing);
// Ctrl+P while running → Pause.
assert!(shell.try_consume_shortcut(true, false, Some('p')));
assert_eq!(shell.state.play, PlayState::Paused);
// Ctrl+. steps while paused.
assert!(shell.try_consume_shortcut(true, false, Some('.')));
assert!(shell.take_step_request());
}
#[test]
fn box_collider_wireframe_has_twelve_edges() {
let col = oxide_physics::Collider::cuboid(oxide_engine::math::Vec3::new(1.0, 2.0, 3.0));
let segs = collider_wire_segments(&col);
assert_eq!(segs.len(), 12, "a box outline is its 12 edges");
// Every vertex sits on the half-extent box corner (|x|=1,|y|=2,|z|=3).
for (a, b) in segs {
for p in [a, b] {
assert!((p.x.abs() - 1.0).abs() < 1e-5);
assert!((p.y.abs() - 2.0).abs() < 1e-5);
assert!((p.z.abs() - 3.0).abs() < 1e-5);
}
}
}
#[test]
fn probe_normal_tip_extends_along_the_unit_normal() {
use oxide_engine::math::Vec3;
let point = Vec3::new(1.0, 2.0, 3.0);
// A non-unit normal is renormalized, then scaled to `len`.
let tip = probe_normal_tip(point, Vec3::new(0.0, 5.0, 0.0), 2.0);
assert!((tip - Vec3::new(1.0, 4.0, 3.0)).length() < 1e-5);
// A degenerate normal collapses to the point (no whisker).
let tip0 = probe_normal_tip(point, Vec3::ZERO, 2.0);
assert!((tip0 - point).length() < 1e-6);
}
#[test]
fn sphere_collider_wireframe_stays_on_the_radius() {
let r = 2.5;
let col = oxide_physics::Collider::ball(r);
let segs = collider_wire_segments(&col);
// Three 24-segment great circles.
assert_eq!(segs.len(), 24 * 3);
for (a, _) in &segs {
assert!(
(a.length() - r).abs() < 1e-4,
"every point is on the sphere"
);
}
}
#[test]
fn capsule_wireframe_spans_the_full_height() {
// Half-height 1.0, radius 0.5 → the cap poles reach +/-1.5 in Y.
let col = oxide_physics::Collider::capsule(0.5, 1.0);
let segs = collider_wire_segments(&col);
let max_y = segs
.iter()
.flat_map(|(a, b)| [a.y, b.y])
.fold(f32::MIN, f32::max);
let min_y = segs
.iter()
.flat_map(|(a, b)| [a.y, b.y])
.fold(f32::MAX, f32::min);
assert!((max_y - 1.5).abs() < 1e-4, "top hemisphere pole at +1.5");
assert!((min_y + 1.5).abs() < 1e-4, "bottom hemisphere pole at -1.5");
}
#[test]
fn push_arc_closes_a_full_circle() {
use oxide_engine::math::Vec3;
let mut segs = Vec::new();
push_arc(
&mut segs,
Vec3::ZERO,
Vec3::X,
Vec3::Z,
1.0,
(0.0, std::f32::consts::TAU),
8,
);
assert_eq!(segs.len(), 8);
// The chain is closed: the last segment's end equals the first's start.
let first_start = segs.first().unwrap().0;
let last_end = segs.last().unwrap().1;
assert!((first_start - last_end).length() < 1e-5);
}
}