//! The editor's mutable runtime state. //! //! Split out from the shell so [commands](crate::commands) can mutate exactly //! the data that participates in undo/redo without taking a borrow of the //! whole shell (which also owns dock layout, dialog flags, and UI buffers). //! //! `EditorState` is the `C` parameter every editor `Command` uses. use std::collections::HashMap; use oxide_engine::asset::{AssetDatabase, AssetServer}; use oxide_engine::input::{ActionMap, ActionOverrides}; use oxide_engine::layer::{GroupRegistry, LayerRegistry}; use oxide_engine::prelude::*; use oxide_engine::project::{Project, RecentProjects}; use oxide_engine::reflect::TypeRegistry; use oxide_engine::settings::Settings; use crate::bindings; use crate::gizmo::{GizmoDrag, GizmoMode, SnapSettings}; /// The data the editor mutates over a session: the scene the user is editing, /// the current selection, the asset server, the open project (if any), the /// typed settings store, and the editor's input action bindings. /// /// Held by the shell; commands operate on `&mut EditorState` so the change is /// guaranteed to flow through the same pipeline whether the user clicks a /// menu, drags a gizmo, or runs a script (Stage 10). pub struct EditorState { /// The scene currently open in the viewport / hierarchy. pub scene: Scene, /// The entity the inspector is bound to, if any. pub selected: Option, /// The asset server shared by every loader (gltf, future texture/audio). /// Cloneable [`Arc`-backed handle](oxide_engine::asset::AssetServer) — cheap /// to hand to the file watcher. pub assets: AssetServer, /// The typed settings store. The shell registers core sections at startup /// (including the [`SETTINGS_SECTION`](crate::bindings::SETTINGS_SECTION) /// for [`actions`](Self::actions)) and modules add their own through the /// [extension API](crate::extension). pub settings: Settings, /// The editor's input action bindings (camera, future gizmo hotkeys, …). /// Default bindings are registered by /// [`bindings::register_defaults`](crate::bindings::register_defaults); /// the preferences UI reads / mutates this map directly, and the /// [`ActionOverrides`](oxide_engine::input::ActionOverrides) settings /// section stays in sync so a write-back through /// [`Settings::export`](oxide_engine::settings::Settings::export) /// captures the user's remap. pub actions: ActionMap, /// The open project, if any. `None` means the user is working in an /// unsaved scratch scene (handy for quick tinkering before saving). pub project: Option, /// The open project's asset database — the bridge between stable asset /// references (`AssetUid`/[`AssetRef`](oxide_engine::asset::AssetRef)) and /// files under `assets/`. `Some` exactly when a [`project`](Self::project) /// is open; the shell scans it on open and rescans when the file watcher /// reports asset changes. The asset browser lists from it and the inspector /// asset-picker resolves through it. pub asset_db: Option, /// The cross-session most-recently-used project list shown in the /// `File / Open Recent` submenu. pub recent: RecentProjects, /// Transform-gizmo UI state: active tool (translate / rotate / scale), /// snap settings, and the in-progress drag if any. The viewport reads /// this each frame to paint handles and dispatch drags; the inspector /// reads it to highlight the active axis. Default is /// [`GizmoMode::Translate`] with the default [`SnapSettings`]. pub gizmo: GizmoState, /// The reflection registry that lets the inspector edit any registered /// component generically — list an entity's components, enumerate each /// one's fields, and get/set a single field by name. Seeded with the /// built-in reflected types (`Transform`, `Node`); modules add their own /// through the extension API. This is what makes the inspector /// reflection-driven instead of hand-coded per type. pub registry: TypeRegistry, /// Per-entity inspector order for **modular** components (the ones the /// user adds and reorders). Entries persist across re-selection. Anything /// currently on the entity that isn't in the map is appended in whatever /// order the registry reports it, so components inserted outside the /// inspector (e.g. by a script or `set_ron`) still show up. /// /// *Node-baked* components — `Node`, `Transform`, `Layer` — render in a /// fixed canonical order above this list and are not tracked here. pub component_order: HashMap>, /// Project-wide layer names (which single layer each entity's [`Layer`] /// index means). Seeded with a small common set (`Default`, `UI`, `Player`, /// `World`); later work persists this to the open project's settings so a /// team can name layers like Unity's Layer Inspector. Layers are the /// *single-valued* membership concept — one per entity. pub layer_registry: LayerRegistry, /// Project-wide gameplay group names — the *multi-valued* counterpart to /// [`layer_registry`](Self::layer_registry). An entity is on one layer but /// in any number of groups (stored in its /// [`Tags`](oxide_engine::layer::Tags) component). The registry is the /// project's fixed vocabulary, so the inspector offers groups to pick from /// rather than free-typed strings. Empty until the user defines groups in /// the Groups editor. pub group_registry: GroupRegistry, /// The UI document currently open in the **UI Canvas** panel, if any. The /// canvas edits this `UiPanel`'s widget tree (via the /// [`WidgetPath`](oxide_engine::ui::WidgetPath) authoring primitives) and /// saves it as a `ui/` asset. `None` means the canvas shows its empty state. pub ui_doc: Option, /// Named spawn templates backing the hierarchy's add-menu. Seeded with the /// built-in prefabs (`Empty`, `Cube`, `Sphere`, `Plane`, `Camera`, /// `Directional Light`); each spawns an entity already carrying the /// matching components via the reflection [`registry`](Self::registry). pub prefab_registry: PrefabRegistry, /// Whether the editor is editing, playing, or paused (Stage 8.7). Drives /// whether the host runner ticks the engine [`Schedule`] and gates the /// play toolbar. Always [`PlayState::Editing`] at startup. pub play: PlayState, /// The scene as it was the instant **Play** was pressed, used to restore it /// bit-for-bit on **Stop** so play-mode mutations never corrupt the authored /// scene. `Some` exactly while [`play`](Self::play) is not /// [`Editing`](PlayState::Editing). See [`enter_play`](Self::enter_play) / /// [`stop`](Self::stop). pub play_snapshot: Option, } /// Whether the editor is authoring the scene or running it (Stage 8.7). /// /// In [`Playing`](Self::Playing) the host runner ticks the engine /// [`Schedule`](oxide_engine::app::Schedule) each frame; [`Paused`](Self::Paused) /// freezes ticking but keeps the scene live so a single **Step** can advance one /// fixed tick and the inspector can still edit fields; [`Editing`](Self::Editing) /// is the normal authoring state where no systems run. Pressing **Play** /// snapshots the scene and pressing **Stop** restores it (see /// [`EditorState::enter_play`] / [`EditorState::stop`]). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum PlayState { /// Authoring; the engine schedule is not ticked. #[default] Editing, /// Running; the schedule is ticked every frame. Playing, /// Running but frozen; the schedule is ticked only one fixed step per Step. Paused, } /// Editor-only transform-gizmo state held on [`EditorState`]. /// /// Decoupled from the gizmo *math* in [`crate::gizmo`] so the math stays /// pure-logic (rays in, transforms out) and this struct carries only the /// per-session UI choices. pub struct GizmoState { /// Which tool is active (toggle with W / E / R while the cursor is /// over the Viewport tab and the camera is in orbit mode). pub mode: GizmoMode, /// The snap step sizes applied during a drag while the snap modifier /// (Ctrl by default) is held. pub snap: SnapSettings, /// `Some` while the user is mid-drag on a handle; the runner /// recomputes the target's transform each frame via /// [`crate::gizmo::apply_drag`]. pub drag: Option, } impl Default for GizmoState { fn default() -> Self { Self { mode: GizmoMode::Translate, snap: SnapSettings::default(), drag: None, } } } /// An open UI document in the editor's **UI Canvas**. /// /// Holds the [`UiPanel`] being authored, the asset it loads from / saves to (if /// it has been saved), the currently selected widget (by /// [`WidgetPath`](oxide_engine::ui::WidgetPath)), and whether there are unsaved /// edits. The same `UiPanel` RON the canvas writes is what the runtime loads. pub struct UiDoc { /// The panel (widget tree + pixel/world size) being edited. pub panel: UiPanel, /// The `ui/` asset this document is saved as, once saved. pub asset: Option, /// The widget the property panel is bound to (root by default). pub selected: WidgetPath, /// Whether the document has edits not yet written to disk. pub dirty: bool, } impl UiDoc { /// A new, empty document: a single full-bleed column root at a 1280×720 /// authoring resolution. Not yet associated with an asset. pub fn new() -> Self { let root = Widget::column().with_id("root").with_style(UiLayoutStyle { width: UiSizing::Grow(1.0), height: UiSizing::Grow(1.0), ..Default::default() }); Self { panel: UiPanel::new(root, Vec2::new(1280.0, 720.0), Vec2::new(2.0, 1.125)), asset: None, selected: WidgetPath::root(), dirty: false, } } } impl Default for UiDoc { fn default() -> Self { Self::new() } } /// The settings section holding [`ExternalEditorPrefs`]. pub const EXTERNAL_EDITOR_SECTION: &str = "editor.external_editor"; /// Preferences for opening a script (or other text asset) in an editor — /// registered as the [`EXTERNAL_EDITOR_SECTION`] settings section, editable in /// Preferences, persisted to `~/.config/oxide/editor.ron` like the bindings. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct ExternalEditorPrefs { /// Command to launch, invoked as ` ` (whitespace-split; may /// carry its own flags, e.g. `"code -g"`). **Empty (the default) = auto**: /// run `$VISUAL`/`$EDITOR` in an editor Terminal tab, else `xdg-open`. pub command: String, } impl EditorState { /// A blank state with an empty scene, no open project, and the editor's /// default action bindings registered (`F` toggle, WASD/QE move, Shift /// sprint — see [`bindings`](crate::bindings)). pub fn new() -> Self { Self::with_scene(Scene::new()) } /// Like [`new`](Self::new) but starting from a populated scene — used by /// the shell so the editor has something visible on launch. pub fn with_scene(scene: Scene) -> Self { let mut actions = ActionMap::new(); bindings::register_defaults(&mut actions); let mut settings = Settings::new(); settings.register::(bindings::SETTINGS_SECTION); settings.register::(EXTERNAL_EDITOR_SECTION); let mut registry = TypeRegistry::new(); register_builtin_types(&mut registry); // Seed a small, generally-useful set of named layers (besides the // built-in "Default" at index 0). These are common filter slots, not // generic "Layer 1 / Layer 2" filler; the user renames or extends them // in the Layer Names editor. let mut layer_registry = LayerRegistry::new(); layer_registry.set(1, "UI"); layer_registry.set(2, "Player"); layer_registry.set(3, "World"); Self { scene, selected: None, assets: AssetServer::new(), settings, actions, project: None, asset_db: None, recent: RecentProjects::new(8), gizmo: GizmoState::default(), registry, component_order: HashMap::new(), layer_registry, group_registry: GroupRegistry::new(), ui_doc: None, prefab_registry: builtin_prefabs(), play: PlayState::Editing, play_snapshot: None, } } /// Whether the editor is currently running the scene /// ([`Playing`](PlayState::Playing) or [`Paused`](PlayState::Paused)) — the /// states in which the authored scene is "live" and will be restored on Stop. pub fn is_in_play(&self) -> bool { self.play != PlayState::Editing } /// Enters **Play**: snapshots the current scene (so Stop can restore it) and /// transitions to [`Playing`](PlayState::Playing). No-op if already playing /// or paused — re-entering must not overwrite the original snapshot. pub fn enter_play(&mut self) { if self.is_in_play() { return; } self.play_snapshot = Some(self.scene.snapshot(&self.registry)); self.play = PlayState::Playing; } /// Toggles between [`Playing`](PlayState::Playing) and /// [`Paused`](PlayState::Paused). No-op while [`Editing`](PlayState::Editing) /// (there is nothing to pause). pub fn toggle_pause(&mut self) { self.play = match self.play { PlayState::Playing => PlayState::Paused, PlayState::Paused => PlayState::Playing, PlayState::Editing => return, }; } /// Stops play and restores the scene to its pre-play snapshot bit-for-bit, /// then returns to [`Editing`](PlayState::Editing). The restored scene has /// fresh entity handles, so the selection and any in-flight gizmo drag are /// cleared (the old [`Entity`] no longer exists). No-op while already /// editing. /// /// A failed restore (corrupt component RON) leaves the live scene in place /// but still returns to editing; the caller may log the returned error. pub fn stop(&mut self) -> Result<(), SceneError> { if !self.is_in_play() { return Ok(()); } let result = match self.play_snapshot.take() { Some(snapshot) => snapshot.restore(&self.registry).map(|scene| { self.scene = scene; }), None => Ok(()), }; self.selected = None; self.gizmo.drag = None; self.play = PlayState::Editing; result } /// Mirrors the current [`actions`](Self::actions) overrides into the /// `input.bindings` settings section so the next /// [`Settings::export`](oxide_engine::settings::Settings::export) round- /// trips them. Called by the shell after every binding edit. pub fn sync_action_overrides_to_settings(&mut self) { let overrides = self.actions.overrides(); self.settings .set::(bindings::SETTINGS_SECTION, overrides); } /// Applies any [`ActionOverrides`] previously /// [`Settings::import`](oxide_engine::settings::Settings::import)'d into /// the `input.bindings` section on top of the registered defaults. /// Called by the host runner at startup, after loading the on-disk /// preferences file. No-op if the section is empty or unregistered. pub fn apply_action_overrides_from_settings(&mut self) { if let Some(o) = self .settings .get::(bindings::SETTINGS_SECTION) { // Clone to release the immutable borrow before mutating actions. let o = o.clone(); self.actions.apply_overrides(&o); } } } impl Default for EditorState { fn default() -> Self { Self::new() } } /// Registers the engine's built-in reflected component types under stable /// names. Kept separate so the shell (and tests) seed a registry identically, /// and so modules layer their own `register_reflected` calls on top. /// /// Transform and Node are reflected but **not** addable (every scene entity /// already carries them). `MeshRenderer` is addable, so it shows up in the /// inspector's "Add Component" menu and is copied by Duplicate. `PrimitiveShape` /// registers as an enum so its inspector widget is a dropdown. fn register_builtin_types(registry: &mut TypeRegistry) { // Node-baked components: reflected so the inspector can read/write them, // but **not** addable — every entity carries them inherently // (auto-attached on `Scene::spawn`), so the Add Component menu must not // offer to attach a duplicate. registry.register_reflected::("Transform"); registry.register_reflected::("Node"); registry.register_reflected::("Layer"); // Modular components: addable from the inspector. Having several distinct // addable types is what lets the user attach more than one component to a // node and drag-reorder them (an archetypal ECS allows only one component // of a given type per entity, so a *second* mesh lives on a child — see the // Add Component menu's "as child" path). registry.register_addable::("MeshRenderer"); registry.register_enum::("PrimitiveShape"); registry.register_addable::("Camera"); registry.register_addable::("DirectionalLight"); // Stage-9 physics components: addable from the inspector and captured by the // play-mode snapshot (so Stop reverts a simulated body). No per-type editor // code — the reflection-driven inspector renders them from their fields, with // the two shape/kind enums shown as dropdowns. registry.register_addable::("RigidBody"); registry.register_enum::("RigidBodyKind"); registry.register_addable::("Collider"); registry.register_enum::("ColliderShape"); registry.register_addable::("CharacterController"); // Stage-10 scripting: the Script component is addable from the inspector and // captured by the play-mode snapshot (so Stop reverts a script attach/detach). // Its `source` field is an `AssetRef`, which the inspector shows // as a picker filtered to the `scripts/` folder. registry.register_addable::("Script"); } /// The built-in prefabs the hierarchy add-menu offers. Data-driven via /// [`ComponentSpec`]: each prefab is a node name plus the components to attach, /// applied on spawn through the reflection registry. The type names here must /// match those registered in [`register_builtin_types`]. fn builtin_prefabs() -> PrefabRegistry { use oxide_engine::render::{Camera, DirectionalLight, MeshRenderer, PrimitiveShape}; let mut reg = PrefabRegistry::new(); // A bare node — just the node-baked Node/Transform/Layer. reg.register(Prefab::new("Empty")); // Primitive meshes (each a MeshRenderer with the matching shape). for (name, shape) in [ ("Cube", PrimitiveShape::Cube), ("Sphere", PrimitiveShape::Sphere), ("Plane", PrimitiveShape::Plane), ] { let mesh = MeshRenderer { shape, ..MeshRenderer::default() }; if let Some(spec) = ComponentSpec::of("MeshRenderer", &mesh) { reg.register(Prefab::new(name).with(spec)); } } // Viewpoint + light entities. if let Some(spec) = ComponentSpec::of("Camera", &Camera::default()) { reg.register(Prefab::new("Camera").with(spec)); } if let Some(spec) = ComponentSpec::of("DirectionalLight", &DirectionalLight::default()) { reg.register(Prefab::new("Directional Light").with(spec)); } reg } #[cfg(test)] mod tests { use super::*; use oxide_engine::math::{Transform, Vec3}; /// An editor state with one entity, ready to play. fn state_with_entity() -> (EditorState, Entity) { let mut state = EditorState::new(); let e = state.scene.spawn("thing", Transform::IDENTITY); (state, e) } #[test] fn enter_play_snapshots_and_sets_playing() { let (mut state, _) = state_with_entity(); assert_eq!(state.play, PlayState::Editing); assert!(state.play_snapshot.is_none()); state.enter_play(); assert_eq!(state.play, PlayState::Playing); assert!(state.play_snapshot.is_some()); } #[test] fn re_entering_play_does_not_overwrite_the_snapshot() { let (mut state, e) = state_with_entity(); state.enter_play(); let original = state.play_snapshot.clone(); // Mutate, then (defensively) call enter_play again — the snapshot must // remain the *pre-play* one so Stop still reverts correctly. state .scene .set_local_transform(e, Transform::from_translation(Vec3::X)); state.enter_play(); assert_eq!(state.play_snapshot, original); } #[test] fn toggle_pause_flips_only_while_in_play() { let (mut state, _) = state_with_entity(); // No-op while editing. state.toggle_pause(); assert_eq!(state.play, PlayState::Editing); state.enter_play(); state.toggle_pause(); assert_eq!(state.play, PlayState::Paused); state.toggle_pause(); assert_eq!(state.play, PlayState::Playing); } #[test] fn stop_restores_the_scene_and_clears_play_state() { let (mut state, e) = state_with_entity(); let before = state.scene.to_ron().unwrap(); state.selected = Some(e); state.enter_play(); // Simulate a play-mode mutation (as a tick would). state .scene .set_local_transform(e, Transform::from_translation(Vec3::new(5.0, 0.0, 0.0))); assert_ne!(state.scene.to_ron().unwrap(), before); state.stop().unwrap(); assert_eq!(state.play, PlayState::Editing); assert!(state.play_snapshot.is_none()); // Scene reverted bit-for-bit; selection dropped (handles changed). assert_eq!(state.scene.to_ron().unwrap(), before); assert!(state.selected.is_none()); } #[test] fn stop_while_editing_is_a_noop() { let (mut state, _) = state_with_entity(); let before = state.scene.to_ron().unwrap(); state.stop().unwrap(); assert_eq!(state.play, PlayState::Editing); assert_eq!(state.scene.to_ron().unwrap(), before); } #[test] fn physics_components_are_addable_and_reflected() { let state = EditorState::new(); // Editable via the reflection-driven inspector and offered in the Add // Component menu (addable), with no per-type editor code. for name in ["RigidBody", "Collider", "CharacterController"] { assert!(state.registry.is_registered(name), "{name} not registered"); } } #[test] fn the_script_component_is_addable_and_reflected() { // Stage-10 dual-editability: Script is registered like any other // component, so the inspector offers it in Add Component and renders its // fields generically. let state = EditorState::new(); assert!(state.registry.is_registered("Script")); } #[test] fn stop_reverts_a_script_attach() { // Attaching a Script during play must be undone on Stop — the snapshot // captures the reflected Script component like any other. let mut state = EditorState::new(); let e = state.scene.spawn("scripted", Transform::IDENTITY); state.enter_play(); // The "running game" attaches a script at play time. state .scene .world_mut() .insert_one(e, oxide_script::Script::default()) .unwrap(); state.stop().unwrap(); let restored = state .scene .entities() .find(|&e| state.scene.name(e).as_deref() == Some("scripted")) .expect("the entity should be restored"); assert!( state.scene.get::(restored).is_none(), "the play-time script attach should be reverted on Stop" ); } #[test] fn stop_reverts_a_simulated_physics_body() { // A body that "fell" during play must be restored on Stop — the snapshot // captures reflected physics components like any other. let mut state = EditorState::new(); let e = state.scene.spawn( "ball", Transform::from_translation(Vec3::new(0.0, 5.0, 0.0)), ); state .scene .world_mut() .insert_one(e, oxide_physics::RigidBody::default()) .unwrap(); state .scene .world_mut() .insert_one(e, oxide_physics::Collider::ball(0.5)) .unwrap(); state.enter_play(); // Simulate physics moving the body down (as the play tick would). state .scene .set_local_transform(e, Transform::from_translation(Vec3::new(0.0, 1.0, 0.0))); state.stop().unwrap(); // Snapshot restore respawns entities (handles change), so find by name // and confirm both the Transform and the physics components came back. let restored = state .scene .entities() .find(|&e| state.scene.name(e).as_deref() == Some("ball")) .expect("the ball entity should be restored"); assert_eq!( state.scene.world_transform(restored).unwrap().translation, Vec3::new(0.0, 5.0, 0.0), "transform should revert to the pre-play pose" ); let collider = state .scene .get::(restored) .expect("the Collider component should be restored"); assert_eq!(collider.radius, 0.5); assert!(state .scene .get::(restored) .is_some()); } #[test] fn external_editor_section_is_registered_and_defaults_to_auto() { let state = EditorState::new(); let prefs = state .settings .get::(EXTERNAL_EDITOR_SECTION) .expect("external-editor settings section must be registered"); assert!( prefs.command.is_empty(), "default is empty = auto ($VISUAL/$EDITOR terminal tab, else xdg-open)" ); } }