//! 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, } /// 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) { 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 { 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, 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 { 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 { 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, /// Editor-wide undo/redo. Cleared on project open; bounded capacity. commands: CommandStack, /// 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, watcher_events: Option>, // --- 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>)>, /// 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, 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, /// 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, /// 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, /// 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, /// 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) { 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) { 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) { 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 + '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 { 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 { // 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) -> 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, name: impl Into, ) -> 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 = 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) -> 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) { 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 = 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 = 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::(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 ` ` (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 = 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 = 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 = 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) -> 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)> = 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 = None; let mut to_add: Option = 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 = 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) { 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 = { 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, extensions: &'a mut EditorExtensions, pending: &'a mut Vec, 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, /// 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, /// The interactive PTY terminal sessions shown as tabs (the Terminal panel /// renders/drives them). Borrowed from [`Shell::terminals`]. terminals: &'a mut Vec, /// 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 = 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 = 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 = 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::(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 = 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 = 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 = { 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 = 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 = self .state .scene .world() .get::<&Tags>(selected) .map(|t| t.iter().map(String::from).collect()) .unwrap_or_default(); let defined: Vec = 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 = 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 ¤t { 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 = 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, } // Phase 1: gather metadata + current values (immutable borrows only). let mut comps: Vec = 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::>(&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) -> Option { 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 /// ` `; /// 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::(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 = 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 { // 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 = None; ui.horizontal(|ui| { ui.label(info.name); egui::ComboBox::from_id_salt(("oxide.field.enum", entity, info.name)) .selected_text(¤t_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` / `Handle`) 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` serializes as `Option`), 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 { // The current value, if any. A malformed value is treated as empty. let cur: Option = 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> = 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 { 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 = None; let mut open: Option = None; let mut start_rename: Option = None; let mut delete: Option = 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::(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 = 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 = 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::(&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 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), font_asset: &mut Option>, ) -> 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 = 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 { 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::>() .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::(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::(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 { 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, ) { 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, ) { 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` visual field. Returns /// whether it changed. fn optional_color(ui: &mut egui::Ui, label: &str, slot: &mut Option) -> 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 = 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, ®), "Default"); // seeded by LayerRegistry assert_eq!(layer_label(2, ®), "Player"); assert_eq!(layer_label(7, ®), "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, ®), "All"); assert_eq!(mask_summary(LayerMask::NONE, ®), "None"); let mask = LayerMask::NONE.with(1).with(2).with(5); assert_eq!(mask_summary(mask, ®), "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::(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 " 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::(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); } }