//! 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, AssetEntry};
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, PlayState};
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.
enum PendingAction {
    /// Spawn a named prefab as a root entity (data-driven add-menu). The
    /// `Empty` prefab is a bare node; the rest carry components.
    AddRootPrefab(String),
    /// Spawn a named prefab as a child of `entity`.
    AddChildPrefab(Entity, String),
    Delete(Entity),
    /// Duplicate an entity as a sibling, copying its registered components
    /// (via the reflection registry). Children are not duplicated yet.
    Duplicate(Entity),
    SetEnabled(Entity, bool),

    // --- Bindings preferences page (Stage 7 piece 5) ------------------
    /// Restore every editor action to its defaults.
    RestoreAllBindings,
    /// Restore one action to its defaults (button or axis or 2D axis —
    /// the [`ActionMap`] dispatches by name across kinds).
    RestoreActionDefaults(String),
    /// Drop one binding from a button action's current list.
    RemoveButtonBinding {
        action: String,
        index: usize,
    },
    /// Drop one binding from a direction-set of an axis (1D or 2D).
    RemoveAxisBinding {
        action: String,
        dir: AxisDirection,
        index: usize,
    },
}

/// Discriminator used by the bindings UI to talk about "one direction of an
/// axis" without caring whether the action is 1D or 2D. Converted to a
/// [`CaptureTarget`] when capture begins and back to the right axis edit
/// when applying pending actions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisDirection {
    Axis(AxisSide),
    Axis2D(Axis2DSide),
}

impl AxisDirection {
    /// Short label shown next to the direction row in the bindings UI.
    fn label(&self) -> &'static str {
        match self {
            AxisDirection::Axis(AxisSide::Positive) => "+",
            AxisDirection::Axis(AxisSide::Negative) => "−",
            AxisDirection::Axis2D(Axis2DSide::Right) => "→",
            AxisDirection::Axis2D(Axis2DSide::Left) => "←",
            AxisDirection::Axis2D(Axis2DSide::Up) => "↑",
            AxisDirection::Axis2D(Axis2DSide::Down) => "↓",
        }
    }
}

/// One frame's data needed to paint the gizmo handles over the Viewport
/// tab. The host (binary) sets it before [`Shell::build`] runs; the Shell
/// stashes it on a field that the Viewport tab reads.
///
/// Decoupled from the gizmo *math* in [`crate::gizmo`] so the math stays
/// pure and the renderer can change (egui overlay today, native 3D mesh
/// in a later piece) without touching either side.
#[derive(Debug, Clone, Copy)]
pub struct ViewportOverlay {
    /// The view-projection matrix the renderer drew the scene with — used
    /// to project handle world points to screen pixels here.
    pub view_proj: Mat4,
    /// World-space length of axis arrows / circles / cubes. Same value
    /// `gizmo::hit_test` was called with, so what the user sees matches
    /// where clicks land.
    pub gizmo_size: f32,
}

/// A frozen raycast-probe visualization the Viewport tab paints when **View ▸
/// Raycast Probe** is on (Stage 9 piece 8c).
///
/// On a viewport click the host casts the editor camera→cursor ray against the
/// edited scene's colliders (via
/// [`PhysicsWorld::sync_to_scene`](oxide_physics::PhysicsWorld::sync_to_scene) +
/// [`raycast`](oxide_physics::PhysicsWorld::raycast)) and **freezes** the result
/// here. The tab redraws it in world space every frame, so orbiting the camera
/// reveals the ray as a real 3D line — a ray cast from the live camera is
/// otherwise just a point in that same camera's view. Like [`ViewportOverlay`]
/// it is `Copy` and projected with the same view-projection the scene was drawn
/// with.
#[derive(Debug, Clone, Copy)]
pub struct RaycastProbeViz {
    /// World-space ray origin (the camera eye-point under the cursor).
    pub origin: Vec3,
    /// World-space end of the drawn ray: the hit point on a hit, else the ray
    /// extended to its probe distance on a miss.
    pub end: Vec3,
    /// The surface the ray struck, if any.
    pub hit: Option<RaycastProbeHit>,
}

/// The surface a [`RaycastProbeViz`] ray struck.
#[derive(Debug, Clone, Copy)]
pub struct RaycastProbeHit {
    /// World-space hit point on the collider surface.
    pub point: Vec3,
    /// World-space unit surface normal at the hit.
    pub normal: Vec3,
}

/// Builds a [`CaptureTarget`] for one direction row of an axis action.
fn make_axis_capture(action: &str, dir: AxisDirection, slot: BindingSlot) -> CaptureTarget {
    match dir {
        AxisDirection::Axis(side) => CaptureTarget::Axis {
            action: action.to_string(),
            side,
            slot,
        },
        AxisDirection::Axis2D(side) => CaptureTarget::Axis2D {
            action: action.to_string(),
            side,
            slot,
        },
    }
}

/// Single flat status-bar line and how long it remains visible.
struct StatusLine {
    text: String,
    /// Wall-clock frames remaining (decremented in `frame_tick`). 0 = hidden.
    ttl: u32,
}

impl StatusLine {
    fn idle() -> Self {
        Self {
            text: String::new(),
            ttl: 0,
        }
    }

    fn say(&mut self, text: impl Into<String>) {
        self.text = text.into();
        // ~5 seconds at 60 FPS — short-lived but readable.
        self.ttl = 300;
    }
}

/// Which slot of an action's binding list the user is currently rebinding.
///
/// The bindings preferences page enters one of these states when the user
/// clicks a "Change" / "Add" button; the host runner consumes the next
/// pressed key or mouse button into that slot via
/// [`Shell::try_complete_capture`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CaptureTarget {
    /// One of a button action's bindings (`Replace` an existing index, or
    /// `Append` a new binding to the list).
    Button { action: String, slot: BindingSlot },
    /// One of a 1D-axis action's direction-set bindings.
    Axis {
        action: String,
        side: AxisSide,
        slot: BindingSlot,
    },
    /// One of a 2D-axis action's four direction-set bindings.
    Axis2D {
        action: String,
        side: Axis2DSide,
        slot: BindingSlot,
    },
}

/// Whether a capture replaces an existing binding at a given index, or
/// appends a new one to the slot's binding list.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindingSlot {
    Replace(usize),
    Append,
}

/// The two directions of a 1D axis (positive = right/forward/up).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AxisSide {
    Positive,
    Negative,
}

/// The four direction-sets of a 2D axis.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Axis2DSide {
    Right,
    Left,
    Up,
    Down,
}

impl CaptureTarget {
    /// The action name this capture targets — used by status messages and
    /// the bindings UI to label the in-progress capture.
    pub fn action(&self) -> &str {
        match self {
            CaptureTarget::Button { action, .. }
            | CaptureTarget::Axis { action, .. }
            | CaptureTarget::Axis2D { action, .. } => action.as_str(),
        }
    }
}

/// Every key/mouse-button the bindings preferences page will offer to bind
/// when the user clicks "Change". Curated to keep the UI scrollbar humane;
/// the underlying `ActionMap` accepts every `KeyCode`/`MouseButton` so the
/// list can be extended without breaking the model. Iteration order is the
/// order shown in the UI.
const BINDABLE_KEYS: &[KeyCode] = &[
    // Letters
    KeyCode::KeyA,
    KeyCode::KeyB,
    KeyCode::KeyC,
    KeyCode::KeyD,
    KeyCode::KeyE,
    KeyCode::KeyF,
    KeyCode::KeyG,
    KeyCode::KeyH,
    KeyCode::KeyI,
    KeyCode::KeyJ,
    KeyCode::KeyK,
    KeyCode::KeyL,
    KeyCode::KeyM,
    KeyCode::KeyN,
    KeyCode::KeyO,
    KeyCode::KeyP,
    KeyCode::KeyQ,
    KeyCode::KeyR,
    KeyCode::KeyS,
    KeyCode::KeyT,
    KeyCode::KeyU,
    KeyCode::KeyV,
    KeyCode::KeyW,
    KeyCode::KeyX,
    KeyCode::KeyY,
    KeyCode::KeyZ,
    // Top row
    KeyCode::Digit0,
    KeyCode::Digit1,
    KeyCode::Digit2,
    KeyCode::Digit3,
    KeyCode::Digit4,
    KeyCode::Digit5,
    KeyCode::Digit6,
    KeyCode::Digit7,
    KeyCode::Digit8,
    KeyCode::Digit9,
    // Whitespace + arrows + modifiers
    KeyCode::Space,
    KeyCode::Tab,
    KeyCode::Enter,
    KeyCode::Backspace,
    KeyCode::ArrowLeft,
    KeyCode::ArrowRight,
    KeyCode::ArrowUp,
    KeyCode::ArrowDown,
    KeyCode::ShiftLeft,
    KeyCode::ShiftRight,
    KeyCode::ControlLeft,
    KeyCode::ControlRight,
    KeyCode::AltLeft,
    KeyCode::AltRight,
    // Function keys
    KeyCode::F1,
    KeyCode::F2,
    KeyCode::F3,
    KeyCode::F4,
    KeyCode::F5,
    KeyCode::F6,
    KeyCode::F7,
    KeyCode::F8,
    KeyCode::F9,
    KeyCode::F10,
    KeyCode::F11,
    KeyCode::F12,
];

/// The mouse buttons offered as bindings. `Left` is excluded by default so
/// the click that initiated a capture cannot accidentally bind itself
/// (egui's button uses left-click and the runner pumps that into
/// `InputState` before the capture state machine sees the next frame —
/// excluding it removes the whole class of footguns).
const BINDABLE_MOUSE_BUTTONS: &[MouseButton] = &[
    MouseButton::Right,
    MouseButton::Middle,
    MouseButton::Back,
    MouseButton::Forward,
];

/// Scans `input` for the first pressed-this-frame key or mouse button (in
/// curated order) and wraps it as a [`Binding`]. Returns `None` when
/// nothing in the curated list was newly pressed this frame. Escape is
/// **not** treated as bindable — the capture state machine reserves it
/// for "cancel".
fn pick_capture_binding(input: &InputState) -> Option<Binding> {
    for &key in BINDABLE_KEYS {
        if input.pressed(key) {
            return Some(Binding::Key(key));
        }
    }
    for &button in BINDABLE_MOUSE_BUTTONS {
        if input.mouse_pressed(button) {
            return Some(Binding::Mouse(button));
        }
    }
    None
}

/// Applies a captured `binding` to the target slot of the appropriate
/// binding list, mutating `actions` in place. No-op when the action is
/// unregistered (the UI is built against `actions`, so this should not
/// happen in practice).
fn apply_capture(
    actions: &mut oxide_engine::input::ActionMap,
    target: &CaptureTarget,
    binding: Binding,
) {
    match target {
        CaptureTarget::Button { action, slot } => {
            let mut bindings = actions.bindings(action).to_vec();
            apply_to_list(&mut bindings, *slot, binding);
            actions.set_bindings(action, bindings);
        }
        CaptureTarget::Axis { action, side, slot } => {
            let Some(current) = actions.axis_bindings(action) else {
                return;
            };
            let mut next = current.clone();
            apply_to_list(side_list_mut(&mut next, *side), *slot, binding);
            actions.set_axis_bindings(action, next);
        }
        CaptureTarget::Axis2D { action, side, slot } => {
            let Some(current) = actions.axis_2d_bindings(action) else {
                return;
            };
            let mut next = current.clone();
            apply_to_list(side_2d_list_mut(&mut next, *side), *slot, binding);
            actions.set_axis_2d_bindings(action, next);
        }
    }
}

fn apply_to_list(list: &mut Vec<Binding>, slot: BindingSlot, binding: Binding) {
    match slot {
        BindingSlot::Replace(i) if i < list.len() => list[i] = binding,
        BindingSlot::Replace(_) | BindingSlot::Append => list.push(binding),
    }
}

fn side_list_mut(axis: &mut oxide_engine::input::AxisBinding, side: AxisSide) -> &mut Vec<Binding> {
    match side {
        AxisSide::Positive => &mut axis.positive,
        AxisSide::Negative => &mut axis.negative,
    }
}

fn side_2d_list_mut(
    axis: &mut oxide_engine::input::Axis2DBinding,
    side: Axis2DSide,
) -> &mut Vec<Binding> {
    match side {
        Axis2DSide::Right => &mut axis.x.positive,
        Axis2DSide::Left => &mut axis.x.negative,
        Axis2DSide::Up => &mut axis.y.positive,
        Axis2DSide::Down => &mut axis.y.negative,
    }
}

/// Human-readable label for a binding (used in the UI and status messages).
fn describe_binding(b: &Binding) -> String {
    match b {
        Binding::Key(k) => format!("{k:?}"),
        Binding::Mouse(m) => format!("Mouse({m:?})"),
    }
}

/// The editor's docking shell.
///
/// Owns the dock state, the command stack, the extension registry, and the
/// transient UI state (dialog open flags, edit buffers, file-watcher events
/// queued for the next frame). [`EditorState`] is the heap of mutable runtime
/// data that commands act on; the shell holds it.
pub struct Shell {
    /// Runtime state commands mutate (scene, selection, assets, settings,
    /// project).
    pub state: EditorState,
    /// Layout of every dockable panel. Persisted layouts land in a later
    /// piece; today the default is built in [`Shell::default_dock`].
    dock: DockState<PanelKind>,
    /// Editor-wide undo/redo. Cleared on project open; bounded capacity.
    commands: CommandStack<EditorState>,
    /// Module-contributed UI (menu items, panels, settings pages…). Populated
    /// by the host before [`build`](Self::build) runs.
    pub extensions: EditorExtensions,
    /// Active file watcher (if a project is open) and its event receiver. The
    /// shell pumps the receiver each frame in [`frame_tick`](Self::frame_tick).
    watcher: Option<FileWatcher>,
    watcher_events: Option<std::sync::mpsc::Receiver<ChangeEvent>>,

    // --- dialog flags --------------------------------------------------
    show_preferences: bool,
    show_about: bool,
    show_new_project: bool,
    show_open_project: bool,
    /// The "Edit layer names" modal — opened from the `Layer` dropdown so the
    /// project can name layers `Player`, `Enemy`, `World`, … instead of bare
    /// numeric indices.
    show_layer_editor: bool,
    /// The "Groups" modal — opened from the `Groups` dropdown so the project
    /// can define the gameplay groups an entity may be tagged with.
    show_group_editor: bool,

    // --- modal scratch -------------------------------------------------
    /// Text entered in the "New Project" modal (path + display name).
    new_project_path: String,
    new_project_name: String,
    /// Text entered in the "Open Project" modal.
    open_project_path: String,
    /// Text entered in the "Groups" editor's "add group" field.
    new_group_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)>,

    // --- per-frame UI scratch -----------------------------------------
    pending: Vec<PendingAction>,
    rename_buf: String,
    /// Euler-degrees buffer for the inspector's quaternion fields. A `Quat`
    /// field is edited as Euler angles (raw quaternions are unusable by hand);
    /// the buffer holds the in-progress angles and is re-synced from the stored
    /// quaternion only when the edited field changes (so the displayed angles
    /// don't jump mid-edit from lossy quat↔euler round-trips). Keyed by which
    /// `(entity, type, field)` it currently mirrors.
    rot_euler: Vec3,
    euler_for: Option<(Entity, &'static str, &'static str)>,
    /// Most recently seen status text + remaining frames.
    status: StatusLine,

    /// Set by the bindings preferences page when the user clicks "Change" /
    /// "Add". The host polls [`Shell::try_complete_capture`] each frame and
    /// consumes the next pressed key/button into the selected slot.
    capture: Option<CaptureTarget>,
    /// Set to `true` whenever the input bindings change so the host can
    /// flush the editor preferences file to disk. Cleared by
    /// [`Shell::take_bindings_dirty`].
    bindings_dirty: bool,

    /// Per-frame projection data the host feeds in before [`build`](Self::build)
    /// runs, so the Viewport tab can paint the gizmo overlay using the same
    /// math the renderer drew with. `None` means no overlay this frame.
    viewport_overlay: Option<ViewportOverlay>,

    /// Set by the play toolbar's **Step** button while paused; the host polls
    /// [`take_step_request`](Self::take_step_request) once per frame and, if set,
    /// advances the play [`App`](oxide_engine::app::App) exactly one fixed tick.
    /// One-shot: a single click steps once.
    step_requested: bool,

    /// View ▸ Show Colliders: when set, the Viewport tab paints a wireframe
    /// outline of every entity's physics `Collider` (Stage 9 piece 8b). On by
    /// default — the usual DCC convention so colliders are visible the moment
    /// one is added.
    show_colliders: bool,

    /// View ▸ Raycast Probe: when on, a viewport click casts the editor
    /// camera→cursor ray against the scene's colliders and **freezes** the
    /// result into the world (Stage 9 piece 8c). Off by default — a debug aid
    /// to verify the raycast API and inspect colliders.
    raycast_probe: bool,

    /// The frozen probe ray, drawn by the Viewport tab every frame in world
    /// space (so orbiting the camera reveals it as a real 3D line). Set on a
    /// probe click via [`set_raycast_probe_viz`](Self::set_raycast_probe_viz);
    /// cleared when the probe is toggled off. `None` = nothing cast yet.
    raycast_probe_viz: Option<RaycastProbeViz>,

    /// The interactive PTY terminal sessions shown as tabs in the Terminal panel
    /// (a shell, an AI-agent CLI, …). Empty until one is launched; a session is
    /// dropped (which kills its child) when closed or when its program exits.
    terminals: Vec<crate::pty::PtyTerminal>,
    /// Index of the active terminal tab within [`terminals`](Self::terminals).
    active_terminal: usize,
}

impl Shell {
    /// A shell with a starter scene and the default dock layout.
    pub fn new() -> Self {
        Self::from_state(EditorState::with_scene(starter_scene()))
    }

    /// A shell wrapping the given state (useful for tests).
    pub fn from_state(state: EditorState) -> Self {
        Self {
            state,
            dock: Self::default_dock(),
            commands: CommandStack::with_capacity(256),
            extensions: EditorExtensions::new(),
            watcher: None,
            watcher_events: None,
            show_preferences: false,
            show_about: false,
            show_new_project: false,
            show_open_project: false,
            show_layer_editor: false,
            show_group_editor: false,
            new_project_path: String::new(),
            new_project_name: String::new(),
            open_project_path: String::new(),
            new_group_name: String::new(),
            terminal_input: String::new(),
            quit_requested: false,
            viewport_rect_px: None,
            pending: Vec::new(),
            rename_buf: String::new(),
            rot_euler: Vec3::ZERO,
            euler_for: None,
            status: StatusLine::idle(),
            capture: None,
            bindings_dirty: false,
            viewport_overlay: None,
            step_requested: false,
            show_colliders: true,
            raycast_probe: false,
            raycast_probe_viz: None,
            terminals: Vec::new(),
            active_terminal: 0,
        }
    }

    /// Sets the per-frame data the Viewport tab needs to paint the gizmo
    /// overlay. The host calls this before [`build`](Self::build) each
    /// frame; pass `None` to suppress the overlay (e.g. while a modal is
    /// open).
    pub fn set_viewport_overlay(&mut self, overlay: Option<ViewportOverlay>) {
        self.viewport_overlay = overlay;
    }

    /// Whether **View ▸ Raycast Probe** is on. The host polls this each frame
    /// and, when set, computes the probe and feeds it back via
    /// [`set_raycast_probe_viz`](Self::set_raycast_probe_viz).
    pub fn raycast_probe_enabled(&self) -> bool {
        self.raycast_probe
    }

    /// Stores the raycast-probe result for the Viewport tab to paint this frame
    /// (set by the host; `None` clears it).
    pub fn set_raycast_probe_viz(&mut self, viz: Option<RaycastProbeViz>) {
        self.raycast_probe_viz = viz;
    }

    /// Returns whether the user has asked to quit since the last call, and
    /// clears the flag. The host runner calls this in `update` and forwards
    /// to `AppCtx::request_exit`.
    pub fn take_quit_request(&mut self) -> bool {
        std::mem::take(&mut self.quit_requested)
    }

    /// Sets the status-bar hint to `text` for a short fade-out. The host
    /// uses this to surface non-shell events (e.g. the camera-mode toggle)
    /// in the same place as undo/redo and save messages.
    pub fn set_status_hint(&mut self, text: impl Into<String>) {
        self.status.say(text);
    }

    // --- Play-mode controls (Stage 8.7) -----------------------------------

    /// Whether the user asked for a single **Step** since the last call, and
    /// clears the flag. The host polls this each frame and, while paused,
    /// advances the play [`App`](oxide_engine::app::App) one fixed tick.
    pub fn take_step_request(&mut self) -> bool {
        std::mem::take(&mut self.step_requested)
    }

    /// Enters **Play**: snapshots the scene and starts running it. Clears the
    /// undo history so play-mode edits never leak into edit-mode undo (the scene
    /// is restored wholesale on Stop). No-op if already playing or paused.
    pub fn play(&mut self) {
        if self.state.is_in_play() {
            return;
        }
        self.state.enter_play();
        self.commands.clear();
        self.status.say("Playing");
    }

    /// The Play/Resume button's action: starts play while editing, or resumes
    /// (un-pauses) while paused. A no-op while already playing. This is distinct
    /// from [`play`](Self::play), which only ever *starts* play — calling it
    /// while paused does nothing, which is why Resume must route through here.
    pub fn play_or_resume(&mut self) {
        match self.state.play {
            PlayState::Editing => self.play(),
            PlayState::Paused => self.toggle_pause(),
            PlayState::Playing => {}
        }
    }

    /// Toggles **Pause** while in play. No-op while editing.
    pub fn toggle_pause(&mut self) {
        if !self.state.is_in_play() {
            return;
        }
        self.state.toggle_pause();
        let label = if self.state.play == PlayState::Paused {
            "Paused"
        } else {
            "Playing"
        };
        self.status.say(label);
    }

    /// Requests a single fixed-step **Step**. Only meaningful while paused; the
    /// host honours it through [`take_step_request`](Self::take_step_request).
    pub fn request_step(&mut self) {
        if self.state.play == PlayState::Paused {
            self.step_requested = true;
            self.status.say("Step");
        }
    }

    /// **Stops** play and restores the scene to its pre-play snapshot. Clears the
    /// undo history (the restore is not itself undoable). No-op while editing.
    pub fn stop(&mut self) {
        if !self.state.is_in_play() {
            return;
        }
        if let Err(err) = self.state.stop() {
            log::error!("failed to restore scene on Stop: {err}");
            self.status.say(format!("Stop: restore failed: {err}"));
        } else {
            self.status.say("Stopped");
        }
        self.commands.clear();
    }

    // --- Input-bindings capture API (Stage 7 piece 5) ---------------------

    /// Whether the bindings preferences page is currently waiting on the
    /// next key/button press to bind. The host runner uses this to gate
    /// other input handling (e.g. the camera mode toggle) while a capture
    /// is in flight, so the same press cannot do two things.
    pub fn capture_active(&self) -> bool {
        self.capture.is_some()
    }

    /// The target of the current capture, if any. The bindings UI uses this
    /// to render the in-progress slot differently ("Press a key…").
    pub fn current_capture(&self) -> Option<&CaptureTarget> {
        self.capture.as_ref()
    }

    /// Drives the [bindings preferences page](Self::capture_active) into a
    /// "waiting for next key/button" state. Called by the page's UI when the
    /// user clicks Change/Add.
    pub fn begin_capture(&mut self, target: CaptureTarget) {
        self.capture = Some(target);
    }

    /// Cancels any in-progress capture without binding anything.
    pub fn cancel_capture(&mut self) {
        if self.capture.take().is_some() {
            self.status.say("Binding capture cancelled");
        }
    }

    /// Looks at `input` and, if a key or non-`Escape` mouse button became
    /// pressed this frame, commits it as the binding for the currently-
    /// captured slot. `Escape` cancels capture without binding.
    ///
    /// Returns whether the capture finished this call (either committed or
    /// cancelled) so the host can drop other input handling for the rest of
    /// the frame.
    pub fn try_complete_capture(&mut self, input: &InputState) -> bool {
        if self.capture.is_none() {
            return false;
        }
        if input.pressed(KeyCode::Escape) {
            self.cancel_capture();
            return true;
        }
        let Some(binding) = pick_capture_binding(input) else {
            return false;
        };
        // `take` unwrap is safe — guarded by the early-return above.
        let target = self.capture.take().unwrap();
        apply_capture(&mut self.state.actions, &target, binding);
        self.state.sync_action_overrides_to_settings();
        self.bindings_dirty = true;
        self.status.say(format!(
            "Bound {} → {}",
            target.action(),
            describe_binding(&binding)
        ));
        true
    }

    /// Reads-and-clears the bindings-dirty flag. The host calls this each
    /// frame and writes the editor preferences file when it returns `true`.
    pub fn take_bindings_dirty(&mut self) -> bool {
        std::mem::take(&mut self.bindings_dirty)
    }

    /// Pushes `command` onto the shell's undo stack with `EditorState` as
    /// its context. The hosting binary uses this to record changes it made
    /// directly to the scene (e.g. ending a gizmo drag) so the same Ctrl+Z
    /// rolls them back as if the user had used the inspector.
    pub fn push_command(&mut self, command: impl Command<EditorState> + 'static) {
        self.commands.push(command, &mut self.state);
        // Rotation buffers, etc. don't necessarily match the new transform.
        self.euler_for = None;
    }

    /// Restores every editor action to its code-defined defaults and flips
    /// the dirty flag. Called by the "Restore all defaults" button.
    pub fn restore_default_bindings(&mut self) {
        self.state.actions.restore_all_defaults();
        self.state.sync_action_overrides_to_settings();
        self.bindings_dirty = true;
        self.status.say("Restored default bindings");
    }

    /// Whether the physical-pixel cursor lies inside the Viewport tab's
    /// most-recent rect — i.e. over the visible 3D scene rather than an
    /// opaque panel. egui marks the whole tab area as "consumed" because
    /// the tab is technically an egui widget; the host uses this to override
    /// that and route orbit/pan/zoom/pick to the camera.
    pub fn cursor_over_viewport(&self, cursor: (f32, f32)) -> bool {
        let Some((x0, y0, x1, y1)) = self.viewport_rect_px else {
            return false;
        };
        cursor.0 >= x0 && cursor.0 <= x1 && cursor.1 >= y0 && cursor.1 <= y1
    }

    /// The Viewport tab's most-recent rect in physical pixels, as a
    /// [`Rect`](oxide_engine::math::Rect). The host's render path uses this
    /// to restrict the wgpu viewport and projection so the 3D scene
    /// matches the tab's bounds (rather than stretching across the whole
    /// window) and so picking aligns with what the user sees. `None` until
    /// the first UI build (the editor's render falls back to the full
    /// surface in that case).
    pub fn viewport_rect(&self) -> Option<oxide_engine::math::Rect> {
        let (x0, y0, x1, y1) = self.viewport_rect_px?;
        Some(oxide_engine::math::Rect::from_min_size(
            oxide_engine::math::Vec2::new(x0, y0),
            oxide_engine::math::Vec2::new((x1 - x0).max(1.0), (y1 - y0).max(1.0)),
        ))
    }

    /// The shipped layout: Hierarchy on the left, Inspector on the right,
    /// Viewport + UI Canvas in the center (both need the large central area),
    /// Project / Console along the bottom.
    fn default_dock() -> DockState<PanelKind> {
        // The UI Canvas shares the central node with the Viewport as tabs: it
        // needs both horizontal and vertical room, so the thin bottom strip is
        // the wrong home for it.
        let mut dock = DockState::new(vec![PanelKind::Viewport, PanelKind::UiCanvas]);
        let root = NodeIndex::root();
        let surface = dock.main_surface_mut();
        let [_center, _left] = surface.split_left(root, 0.22, vec![PanelKind::Hierarchy]);
        let [center_remaining, _right] =
            surface.split_right(NodeIndex::root(), 0.78, vec![PanelKind::Inspector]);
        // Bottom strip on the center column carries Project + Console as tabs.
        surface.split_below(
            center_remaining,
            0.70,
            vec![PanelKind::Project, PanelKind::Console, PanelKind::Terminal],
        );
        dock
    }

    // ---- project / watcher -------------------------------------------

    /// Opens `path` as a project, clearing the undo history and starting a
    /// file watcher over the project's `assets/`, `scenes/`, and `scripts/`
    /// directories so live-reload is on by default.
    pub fn open_project(&mut self, path: impl Into<PathBuf>) -> Result<(), ProjectError> {
        let path = path.into();
        let project = Project::open(&path)?;
        self.state.recent.record(project.root());
        self.attach_watcher(&project);
        self.open_asset_db(project.root());
        self.state.project = Some(project);
        self.commands.clear();
        self.status.say(format!("Opened {}", path.display()));
        Ok(())
    }

    /// Creates a new project at `path` with the given display name.
    pub fn create_project(
        &mut self,
        path: impl Into<PathBuf>,
        name: impl Into<String>,
    ) -> Result<(), ProjectError> {
        let path = path.into();
        let project = Project::create(&path, name)?;
        self.state.recent.record(project.root());
        // Seed the bundled default UI font so a fresh project has something to
        // pick in the asset browser, referenced by project-relative path.
        match crate::assets::seed_default_font(&project.assets_dir()) {
            Ok(true) => self.status.say("Added default UI font"),
            Ok(false) => {}
            Err(err) => log::warn!("could not seed default font: {err}"),
        }
        self.attach_watcher(&project);
        self.open_asset_db(project.root());
        self.state.project = Some(project);
        self.commands.clear();
        self.status.say(format!("Created {}", path.display()));
        Ok(())
    }

    /// Closes the open project (if any), stopping the watcher.
    pub fn close_project(&mut self) {
        self.state.project = None;
        self.state.asset_db = None;
        self.watcher = None;
        self.watcher_events = None;
        self.commands.clear();
        self.status.say("Closed project");
    }

    /// Opens (and scans) the asset database for the project at `root`, then
    /// persists the manifest so freshly-assigned uids survive the next session.
    /// Stored on [`EditorState::asset_db`] for the browser and asset-picker.
    fn open_asset_db(&mut self, root: &std::path::Path) {
        let mut db = AssetDatabase::open(root);
        db.scan();
        if let Err(err) = db.save() {
            log::warn!("could not write asset manifest: {err}");
        }
        self.state.asset_db = Some(db);
    }

    fn attach_watcher(&mut self, project: &Project) {
        // ~150 ms is the Stage-6 default; long enough to coalesce a "save"
        // burst, short enough that the editor feels responsive.
        match FileWatcher::new(Duration::from_millis(150)) {
            Ok((mut watcher, events)) => {
                let mut failed = false;
                for dir in [
                    project.assets_dir(),
                    project.scenes_dir(),
                    project.scripts_dir(),
                ] {
                    if dir.exists() {
                        if let Err(err) = watcher.watch(&dir) {
                            log::warn!("file watcher could not watch {}: {err}", dir.display());
                            failed = true;
                        }
                    }
                }
                self.watcher = Some(watcher);
                self.watcher_events = Some(events);
                if failed {
                    self.status.say("File watcher partially active (see log)");
                }
            }
            Err(err) => {
                log::warn!("file watcher unavailable: {err}");
                self.watcher = None;
                self.watcher_events = None;
                self.status.say("File watcher unavailable");
            }
        }
    }

    /// Called once per frame (between event handling and rendering) to drain
    /// any settled file-watcher events into the asset server. Cheap when
    /// there's nothing to do.
    pub fn frame_tick(&mut self) {
        if let Some(rx) = &self.watcher_events {
            let mut events: Vec<ChangeEvent> = Vec::new();
            while let Ok(ev) = rx.try_recv() {
                events.push(ev);
            }
            if !events.is_empty() {
                let n = reload_changed_assets(&self.state.assets, events);
                if n > 0 {
                    self.status.say(format!("Reloaded {n} asset(s)"));
                }
                // A watcher burst may have added or removed files under
                // `assets/`; reconcile the database so the browser and picker
                // reflect the change. Cheap (a directory walk) and only when
                // something actually changed on disk.
                if let Some(db) = &mut self.state.asset_db {
                    let added = db.scan();
                    if added > 0 {
                        let _ = db.save();
                        self.status.say(format!("Imported {added} asset(s)"));
                    }
                }
            }
        }
        if self.status.ttl > 0 {
            self.status.ttl -= 1;
        }
    }

    // ---- key bindings -------------------------------------------------

    /// Returns whether `key_event` was handled (so the caller can suppress
    /// further processing). Handles editor-global shortcuts:
    ///
    /// - `Ctrl+Z` — undo
    /// - `Ctrl+Y` / `Ctrl+Shift+Z` — redo
    /// - `Ctrl+S` — save project (no-op without a project)
    /// - `Ctrl+,` — toggle Preferences
    /// - `Ctrl+P` — Play / Pause toggle (Play when editing; Pause⇄Resume when
    ///   running)
    /// - `Ctrl+.` — Step one fixed tick (while paused)
    pub fn try_consume_shortcut(&mut self, ctrl: bool, shift: bool, ch: Option<char>) -> bool {
        if !ctrl {
            return false;
        }
        let Some(ch) = ch.map(|c| c.to_ascii_lowercase()) else {
            return false;
        };
        match ch {
            'z' if !shift => {
                if let Some(label) = self.commands.undo(&mut self.state) {
                    self.status.say(format!("Undo: {label}"));
                }
                true
            }
            'y' | 'z' /* shift+z = redo */ => {
                if let Some(label) = self.commands.redo(&mut self.state) {
                    self.status.say(format!("Redo: {label}"));
                }
                true
            }
            's' => {
                self.save_project();
                true
            }
            ',' => {
                self.show_preferences = !self.show_preferences;
                true
            }
            'p' => {
                // Play when editing; toggle pause while running.
                if self.state.is_in_play() {
                    self.toggle_pause();
                } else {
                    self.play();
                }
                true
            }
            '.' => {
                self.request_step();
                true
            }
            _ => false,
        }
    }

    /// Sets (or clears) the selection from outside the UI pass — used by the
    /// viewport's ray-pick on click. Also refreshes the inspector's rename
    /// buffer and the rotation-euler scratch buffer, so the inspector reflects
    /// the new selection on its next frame.
    pub fn select(&mut self, entity: Option<Entity>) {
        self.state.selected = entity;
        self.rename_buf = entity
            .and_then(|e| self.state.scene.name(e))
            .unwrap_or_default();
        self.euler_for = None;
    }

    fn save_project(&mut self) {
        let Some(project) = &self.state.project else {
            self.status.say("No project open");
            return;
        };
        match project.save() {
            Ok(()) => self.status.say(format!("Saved {}", project.name())),
            Err(err) => self.status.say(format!("Save failed: {err}")),
        }
    }

    // ---- top-level UI build ------------------------------------------

    /// Builds the editor UI for one frame: menu bar, dock area, status bar,
    /// and any open modal windows. Must be idempotent — egui may run the
    /// closure more than once during layout.
    pub fn build(&mut self, ui: &mut egui::Ui) {
        self.pending.clear();

        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,
                    terminal_input: &mut self.terminal_input,
                    rot_euler: &mut self.rot_euler,
                    euler_for: &mut self.euler_for,
                    show_layer_editor: &mut self.show_layer_editor,
                    show_group_editor: &mut self.show_group_editor,
                    viewport_rect_px: &mut self.viewport_rect_px,
                    viewport_overlay: self.viewport_overlay,
                    pixels_per_point,
                    show_colliders: self.show_colliders,
                    raycast_probe_viz: self.raycast_probe_viz,
                    terminals: &mut self.terminals,
                    active_terminal: &mut self.active_terminal,
                };
                DockArea::new(&mut self.dock)
                    .style(Style::from_egui(ui.style().as_ref()))
                    .show_inside(ui, &mut viewer);
            });

        // Apply the structural edits collected during the UI pass.
        let actions = std::mem::take(&mut self.pending);
        for action in actions {
            self.apply(action);
        }
    }

    fn menu_bar(&mut self, ui: &mut egui::Ui) {
        egui::Panel::top("oxide.menu_bar").show_inside(ui, |ui| {
            egui::MenuBar::new().ui(ui, |ui| {
                ui.menu_button("File", |ui| {
                    if ui.button("New Project…").clicked() {
                        // Pre-fill with a sensible default so the user only
                        // has to confirm; they can edit either field.
                        if self.new_project_path.is_empty() {
                            if let Some(home) = std::env::var_os("HOME") {
                                let mut p = PathBuf::from(home);
                                p.push("oxide-projects/untitled");
                                self.new_project_path = p.display().to_string();
                            }
                        }
                        if self.new_project_name.is_empty() {
                            self.new_project_name = "Untitled".to_owned();
                        }
                        self.show_new_project = true;
                        ui.close();
                    }
                    if ui.button("Open Project…").clicked() {
                        if self.open_project_path.is_empty() {
                            // Suggest the last-used project as a starting
                            // point if there is one.
                            if let Some(p) = self.state.recent.entries().last() {
                                self.open_project_path = p.display().to_string();
                            }
                        }
                        self.show_open_project = true;
                        ui.close();
                    }
                    ui.menu_button("Open Recent", |ui| {
                        if self.state.recent.entries().is_empty() {
                            ui.weak("(none)");
                        }
                        let recents: Vec<PathBuf> = self.state.recent.entries().to_vec();
                        for path in recents {
                            if ui.button(path.display().to_string()).clicked() {
                                if let Err(err) = self.open_project(&path) {
                                    self.status.say(format!("Open failed: {err}"));
                                }
                                ui.close();
                            }
                        }
                    });
                    ui.separator();
                    let enabled = self.state.project.is_some();
                    if ui
                        .add_enabled(enabled, egui::Button::new("Save Project"))
                        .clicked()
                    {
                        self.save_project();
                        ui.close();
                    }
                    if ui
                        .add_enabled(enabled, egui::Button::new("Close Project"))
                        .clicked()
                    {
                        self.close_project();
                        ui.close();
                    }
                    ui.separator();
                    if ui.button("Quit (Ctrl+Q)").clicked() {
                        // Flagged here, observed by the host runner in
                        // `update()` next frame — egui's `ViewportCommand::Close`
                        // is a viewport-level signal, not a winit close, so
                        // we route through our own mechanism.
                        self.quit_requested = true;
                        ui.close();
                    }
                });

                ui.menu_button("Edit", |ui| {
                    // Keep the menu labels short — long action names made the
                    // buttons stretch vertically. The label still appears in
                    // the status bar after the action runs.
                    let can_undo = self.commands.can_undo();
                    let can_redo = self.commands.can_redo();
                    if ui
                        .add_enabled(can_undo, egui::Button::new("Undo"))
                        .clicked()
                    {
                        if let Some(label) = self.commands.undo(&mut self.state) {
                            self.status.say(format!("Undo: {label}"));
                        }
                        ui.close();
                    }
                    if ui
                        .add_enabled(can_redo, egui::Button::new("Redo"))
                        .clicked()
                    {
                        if let Some(label) = self.commands.redo(&mut self.state) {
                            self.status.say(format!("Redo: {label}"));
                        }
                        ui.close();
                    }
                });

                ui.menu_button("View", |ui| {
                    for kind in [
                        PanelKind::Hierarchy,
                        PanelKind::Inspector,
                        PanelKind::Viewport,
                        PanelKind::Project,
                        PanelKind::UiCanvas,
                        PanelKind::Console,
                    ] {
                        let visible = self.dock_contains(&kind);
                        if ui
                            .selectable_label(visible, format!("Show {}", kind.title()))
                            .clicked()
                        {
                            self.toggle_panel(kind);
                            ui.close();
                        }
                    }
                    ui.separator();
                    // Viewport overlays (Stage 9 piece 8b: physics collider wireframes).
                    if ui
                        .selectable_label(self.show_colliders, "Show Colliders")
                        .clicked()
                    {
                        self.show_colliders = !self.show_colliders;
                        ui.close();
                    }
                    // Stage 9 piece 8c: raycast debug probe (click to freeze a
                    // camera→cursor ray into the world, then orbit to view it).
                    if ui
                        .selectable_label(self.raycast_probe, "Raycast Probe")
                        .clicked()
                    {
                        self.raycast_probe = !self.raycast_probe;
                        if self.raycast_probe {
                            self.set_status_hint(
                                "Raycast probe on — click in the viewport to cast a debug ray",
                            );
                        } else {
                            self.raycast_probe_viz = None;
                        }
                        ui.close();
                    }
                });

                ui.menu_button("Project", |ui| {
                    if let Some(project) = &self.state.project {
                        ui.label(format!("Open: {}", project.name()));
                        ui.label(project.root().display().to_string());
                    } else {
                        ui.weak("(no project open)");
                    }
                });

                // Module-contributed menu items, attributed to their owning
                // module's group; built-in menu items above are not routed
                // through the registry.
                if self.extensions.iter_menu_items().next().is_some() {
                    ui.menu_button("Modules", |ui| {
                        let paths: Vec<String> = self
                            .extensions
                            .iter_menu_items()
                            .map(|i| i.path.clone())
                            .collect();
                        for path in paths {
                            if ui.button(&path).clicked() {
                                self.invoke_menu_item(&path);
                                ui.close();
                            }
                        }
                    });
                }

                ui.menu_button("Help", |ui| {
                    if ui.button("About Oxide…").clicked() {
                        self.show_about = true;
                        ui.close();
                    }
                });

                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    if ui.button("⚙ Preferences").clicked() {
                        self.show_preferences = !self.show_preferences;
                    }
                });
            });
        });
    }

    /// The play-mode toolbar (Stage 8.7): a centered Play/Pause/Step/Stop row
    /// just below the menu bar. Buttons are gated by the current
    /// [`PlayState`](crate::state::PlayState) — Pause/Step/Stop only enable while
    /// running — and a "PLAYING"/"PAUSED" badge makes edit-vs-play unambiguous.
    /// Text labels (no media glyphs) keep it readable in egui's bundled font.
    fn play_toolbar(&mut self, ui: &mut egui::Ui) {
        egui::Panel::top("oxide.play_toolbar").show_inside(ui, |ui| {
            ui.horizontal(|ui| {
                let playing = self.state.play == PlayState::Playing;
                let paused = self.state.play == PlayState::Paused;
                let in_play = self.state.is_in_play();

                // Play / Resume — disabled while already playing. While paused
                // this resumes (un-pauses); while editing it starts play.
                let play_label = if paused { "Resume" } else { "Play" };
                if ui
                    .add_enabled(!playing, egui::Button::new(play_label))
                    .on_hover_text("Run the scene (Ctrl+P)")
                    .clicked()
                {
                    self.play_or_resume();
                }
                // Pause — only while playing.
                if ui
                    .add_enabled(playing, egui::Button::new("Pause"))
                    .on_hover_text("Freeze the simulation (Ctrl+P)")
                    .clicked()
                {
                    self.toggle_pause();
                }
                // Step — one fixed tick, only while paused.
                if ui
                    .add_enabled(paused, egui::Button::new("Step"))
                    .on_hover_text("Advance one fixed tick (Ctrl+.)")
                    .clicked()
                {
                    self.request_step();
                }
                // Stop — restore the scene, only while running.
                if ui
                    .add_enabled(in_play, egui::Button::new("Stop"))
                    .on_hover_text("Stop and restore the scene")
                    .clicked()
                {
                    self.stop();
                }

                // State badge so edit-vs-play is never ambiguous.
                ui.separator();
                if playing {
                    ui.colored_label(egui::Color32::from_rgb(120, 220, 120), "PLAYING");
                } else if paused {
                    ui.colored_label(egui::Color32::from_rgb(235, 200, 90), "PAUSED");
                } else {
                    ui.weak("Editing");
                }
            });
        });
    }

    fn status_bar(&mut self, ui: &mut egui::Ui) {
        egui::Panel::bottom("oxide.status_bar").show_inside(ui, |ui| {
            ui.horizontal(|ui| {
                if self.status.ttl > 0 {
                    ui.label(&self.status.text);
                } else {
                    ui.weak("ready");
                }
                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                    let depth = self.commands.undo_depth();
                    ui.weak(format!("undo: {depth}"));
                    ui.separator();
                    let modules = self.extensions.modules().count();
                    ui.weak(format!("modules: {modules}"));
                });
            });
        });
    }

    fn preferences_window(&mut self, ui: &mut egui::Ui) {
        let ctx = ui.ctx().clone();
        let mut open = self.show_preferences;
        egui::Window::new("Preferences")
            .open(&mut open)
            .default_size([640.0, 480.0])
            .resizable(true)
            .show(&ctx, |ui| {
                egui::Panel::left("oxide.prefs.sidebar")
                    .default_size(160.0)
                    .show_inside(ui, |ui| {
                        ui.heading("Sections");
                        for name in self.state.settings.names() {
                            ui.label(name);
                        }
                        ui.separator();
                        ui.heading("Modules");
                        let modules: Vec<&'static str> = self.extensions.modules().collect();
                        for m in modules {
                            let mut enabled = self.extensions.is_module_enabled(m);
                            if ui.checkbox(&mut enabled, m).changed() {
                                self.extensions.set_module_enabled(m, enabled);
                            }
                        }
                    });
                egui::CentralPanel::default().show_inside(ui, |ui| {
                    ui.heading("Settings");
                    // Snapshot the section names so the central panel can
                    // mutably borrow `self` (the bindings UI needs to call
                    // `self.input_bindings_page` and edit `self.state`).
                    let section_names: Vec<&'static str> = self.state.settings.names().collect();
                    if section_names.is_empty() {
                        ui.weak(
                            "No settings sections registered yet. Engine, editor, and \
                             modules add sections here.",
                        );
                    }
                    for name in section_names {
                        // The input bindings section gets a rich UI; every
                        // other section still shows raw RON until a per-
                        // section editor lands.
                        if name == crate::bindings::SETTINGS_SECTION {
                            ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui));
                        } else {
                            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;
    }

    /// Renders the input-bindings preferences page: every registered editor
    /// action with its current bindings, a Change/Add/Clear control per
    /// slot, a per-action "Restore defaults" button, and a global
    /// "Restore all defaults" button. While a capture is in flight the
    /// targeted slot shows "Press a key… (Esc to cancel)" and the rest of
    /// the page is read-only.
    fn input_bindings_page(&mut self, ui: &mut egui::Ui) {
        let capturing = self.capture.is_some();
        ui.horizontal(|ui| {
            ui.label(if capturing {
                "Press a key or button to bind · Esc cancels"
            } else {
                "Click a binding to change it · empty slot adds"
            });
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                if ui.button("Restore all defaults").clicked() {
                    self.pending.push(PendingAction::RestoreAllBindings);
                }
            });
        });
        ui.separator();

        // Buttons -----------------------------------------------------------
        let button_actions: Vec<String> =
            self.state.actions.actions().map(str::to_string).collect();
        if !button_actions.is_empty() {
            ui.heading("Buttons");
            for action in &button_actions {
                self.button_row(ui, action, capturing);
            }
            ui.add_space(8.0);
        }

        // 1D axes ----------------------------------------------------------
        let axis_actions: Vec<String> = self.state.actions.axes().map(str::to_string).collect();
        if !axis_actions.is_empty() {
            ui.heading("Axes (1D)");
            for action in &axis_actions {
                self.axis_row(ui, action, capturing);
            }
            ui.add_space(8.0);
        }

        // 2D axes ----------------------------------------------------------
        let axis_2d_actions: Vec<String> =
            self.state.actions.axes_2d().map(str::to_string).collect();
        if !axis_2d_actions.is_empty() {
            ui.heading("Axes (2D)");
            for action in &axis_2d_actions {
                self.axis_2d_row(ui, action, capturing);
            }
        }
    }

    fn button_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) {
        let bindings = self.state.actions.bindings(action).to_vec();
        ui.horizontal(|ui| {
            ui.label(action);
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                if ui.button("↺").on_hover_text("Restore defaults").clicked() {
                    self.pending
                        .push(PendingAction::RestoreActionDefaults(action.to_string()));
                }
                if ui.button("+").on_hover_text("Add binding").clicked() && !capturing {
                    self.capture = Some(CaptureTarget::Button {
                        action: action.to_string(),
                        slot: BindingSlot::Append,
                    });
                }
                for (i, binding) in bindings.iter().enumerate().rev() {
                    if ui.small_button("🗑").on_hover_text("Remove").clicked() {
                        self.pending.push(PendingAction::RemoveButtonBinding {
                            action: action.to_string(),
                            index: i,
                        });
                    }
                    let label = self.slot_label(
                        &CaptureTarget::Button {
                            action: action.to_string(),
                            slot: BindingSlot::Replace(i),
                        },
                        Some(*binding),
                    );
                    if ui.button(label).clicked() && !capturing {
                        self.capture = Some(CaptureTarget::Button {
                            action: action.to_string(),
                            slot: BindingSlot::Replace(i),
                        });
                    }
                }
            });
        });
    }

    fn axis_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) {
        let axis = self.state.actions.axis_bindings(action).cloned();
        let Some(axis) = axis else {
            return;
        };
        ui.horizontal(|ui| {
            ui.label(action);
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                if ui.button("↺").on_hover_text("Restore defaults").clicked() {
                    self.pending
                        .push(PendingAction::RestoreActionDefaults(action.to_string()));
                }
            });
        });
        self.direction_row(
            ui,
            action,
            AxisDirection::Axis(AxisSide::Positive),
            &axis.positive,
            capturing,
        );
        self.direction_row(
            ui,
            action,
            AxisDirection::Axis(AxisSide::Negative),
            &axis.negative,
            capturing,
        );
    }

    fn axis_2d_row(&mut self, ui: &mut egui::Ui, action: &str, capturing: bool) {
        let axis = self.state.actions.axis_2d_bindings(action).cloned();
        let Some(axis) = axis else {
            return;
        };
        ui.horizontal(|ui| {
            ui.label(action);
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                if ui.button("↺").on_hover_text("Restore defaults").clicked() {
                    self.pending
                        .push(PendingAction::RestoreActionDefaults(action.to_string()));
                }
            });
        });
        self.direction_row(
            ui,
            action,
            AxisDirection::Axis2D(Axis2DSide::Right),
            &axis.x.positive,
            capturing,
        );
        self.direction_row(
            ui,
            action,
            AxisDirection::Axis2D(Axis2DSide::Left),
            &axis.x.negative,
            capturing,
        );
        self.direction_row(
            ui,
            action,
            AxisDirection::Axis2D(Axis2DSide::Up),
            &axis.y.positive,
            capturing,
        );
        self.direction_row(
            ui,
            action,
            AxisDirection::Axis2D(Axis2DSide::Down),
            &axis.y.negative,
            capturing,
        );
    }

    fn direction_row(
        &mut self,
        ui: &mut egui::Ui,
        action: &str,
        dir: AxisDirection,
        bindings: &[Binding],
        capturing: bool,
    ) {
        ui.horizontal(|ui| {
            ui.add_space(16.0);
            ui.weak(dir.label());
            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
                if ui.button("+").on_hover_text("Add binding").clicked() && !capturing {
                    self.capture = Some(make_axis_capture(action, dir, BindingSlot::Append));
                }
                for (i, binding) in bindings.iter().enumerate().rev() {
                    if ui.small_button("🗑").on_hover_text("Remove").clicked() {
                        self.pending.push(PendingAction::RemoveAxisBinding {
                            action: action.to_string(),
                            dir,
                            index: i,
                        });
                    }
                    let target = make_axis_capture(action, dir, BindingSlot::Replace(i));
                    let label = self.slot_label(&target, Some(*binding));
                    if ui.button(label).clicked() && !capturing {
                        self.capture = Some(target);
                    }
                }
            });
        });
    }

    /// The label shown on a binding button. Becomes "Press a key…" when the
    /// slot is the active capture target; otherwise the binding's own
    /// description (or "(empty)" for an Append slot with no value yet).
    fn slot_label(&self, target: &CaptureTarget, binding: Option<Binding>) -> String {
        if Some(target) == self.capture.as_ref() {
            return "Press a key…".to_string();
        }
        match binding {
            Some(b) => describe_binding(&b),
            None => "(empty)".to_string(),
        }
    }

    fn about_window(&mut self, ui: &mut egui::Ui) {
        let ctx = ui.ctx().clone();
        let mut open = self.show_about;
        egui::Window::new("About Oxide")
            .open(&mut open)
            .resizable(false)
            .show(&ctx, |ui| {
                ui.heading("Oxide Engine");
                ui.label("In-engine editor — Stage 6 shell.");
                ui.label(format!("Engine v{}", env!("CARGO_PKG_VERSION")));
            });
        self.show_about = open;
    }

    /// Project-wide layer-name editor. Lists the 32 layer slots; each one has
    /// an editable name + a Clear button. Index 0 is `"Default"` by default
    /// and can be renamed but the layer itself can't be removed.
    fn layer_editor_window(&mut self, ui: &mut egui::Ui) {
        use oxide_engine::layer::MAX_LAYERS;
        let ctx = ui.ctx().clone();
        let mut open = self.show_layer_editor;
        // Pull all current names into a parallel String buffer for editing,
        // then write back any that changed when the user edits a row.
        let mut edits: Vec<(u32, Option<String>)> = Vec::new();
        egui::Window::new("Layer Names")
            .open(&mut open)
            .default_size([340.0, 560.0])
            .resizable(true)
            .show(&ctx, |ui| {
                ui.label(
                    "Project-wide layer names. Cameras, raycasts, and physics filters use \
                     these to refer to layers by name instead of bare bit indices.",
                );
                ui.label(
                    egui::RichText::new("Edit a name and press Enter or click away to apply.")
                        .weak(),
                );
                ui.separator();
                // Fill the window width (don't shrink to content) so the name
                // fields can stretch the full row.
                egui::ScrollArea::vertical()
                    .auto_shrink([false, false])
                    .show(ui, |ui| {
                        for i in 0..MAX_LAYERS {
                            let current = self
                                .state
                                .layer_registry
                                .name(i)
                                .map(String::from)
                                .unwrap_or_default();
                            let mut buf = current.clone();
                            ui.horizontal(|ui| {
                                ui.monospace(format!("{i:>2}"));
                                // Reserve room for the trailing clear button so
                                // the field fills the rest of the row and grows
                                // when the window is widened.
                                let field_w = (ui.available_width() - 30.0).max(80.0);
                                let edited = ui.add(
                                    egui::TextEdit::singleline(&mut buf)
                                        .desired_width(field_w)
                                        .hint_text("(unnamed)"),
                                );
                                // Commit on focus loss (Enter or click-away).
                                if edited.lost_focus() && buf != current {
                                    edits.push((
                                        i,
                                        if buf.trim().is_empty() {
                                            None
                                        } else {
                                            Some(buf.trim().to_string())
                                        },
                                    ));
                                }
                                if !current.is_empty()
                                    && ui
                                        .small_button("🗑")
                                        .on_hover_text("Clear this layer's name")
                                        .clicked()
                                {
                                    edits.push((i, None));
                                }
                            });
                        }
                    });
            });
        for (i, new_name) in edits {
            match new_name {
                Some(name) => self.state.layer_registry.set(i, name),
                None => self.state.layer_registry.clear(i),
            }
        }
        self.show_layer_editor = open;
    }

    /// Project-wide gameplay-group editor. Lists the defined groups (each with a
    /// delete button) and an "add group" row. Unlike layers (a fixed 32-slot
    /// bitset) groups are an open-ended named set, so this grows/shrinks freely.
    /// Deleting a group only removes it from the project's vocabulary — entities
    /// already tagged with it keep the tag (shown as an "ungrouped tag" in the
    /// inspector) until cleared there.
    fn group_editor_window(&mut self, ui: &mut egui::Ui) {
        let ctx = ui.ctx().clone();
        let mut open = self.show_group_editor;
        let mut to_remove: Option<String> = None;
        let mut to_add: Option<String> = None;
        egui::Window::new("Groups")
            .open(&mut open)
            .default_size([320.0, 420.0])
            .resizable(true)
            .show(&ctx, |ui| {
                ui.label(
                    "Project-wide gameplay groups. Tag nodes with any of these in the \
                     inspector; game code and scripts query membership by name.",
                );
                ui.separator();
                egui::ScrollArea::vertical()
                    .max_height(320.0)
                    .show(ui, |ui| {
                        let defined: Vec<String> =
                            self.state.group_registry.iter().map(String::from).collect();
                        if defined.is_empty() {
                            ui.weak("No groups defined yet. Add one below.");
                        }
                        for name in defined {
                            ui.horizontal(|ui| {
                                if ui
                                    .small_button("🗑")
                                    .on_hover_text("Delete this group")
                                    .clicked()
                                {
                                    to_remove = Some(name.clone());
                                }
                                ui.label(&name);
                            });
                        }
                    });
                ui.separator();
                ui.horizontal(|ui| {
                    let resp = ui.text_edit_singleline(&mut self.new_group_name);
                    let entered =
                        resp.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter));
                    let add_clicked = ui.button("Add").clicked();
                    if (add_clicked || entered) && !self.new_group_name.trim().is_empty() {
                        to_add = Some(self.new_group_name.trim().to_string());
                    }
                });
            });
        if let Some(name) = to_add {
            self.state.group_registry.define(name);
            self.new_group_name.clear();
        }
        if let Some(name) = to_remove {
            self.state.group_registry.undefine(&name);
        }
        self.show_group_editor = open;
    }

    /// In-app New Project dialog: a path field, a name field, and Create / Cancel.
    /// A native OS file dialog (`rfd` or similar) is a piece-6 polish item — for
    /// now the path is typed, which is enough to exercise the flow end-to-end and
    /// keeps the editor dependency-light.
    fn new_project_window(&mut self, ui: &mut egui::Ui) {
        let ctx = ui.ctx().clone();
        let mut open = self.show_new_project;
        let mut create_now = false;
        let mut cancel_now = false;
        egui::Window::new("New Project")
            .open(&mut open)
            .default_size([520.0, 160.0])
            .resizable(true)
            .show(&ctx, |ui| {
                ui.label("Project folder");
                ui.text_edit_singleline(&mut self.new_project_path);
                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 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: one path field, Open / Cancel.
    fn open_project_window(&mut self, ui: &mut egui::Ui) {
        let ctx = ui.ctx().clone();
        let mut open = self.show_open_project;
        let mut open_now = false;
        let mut cancel_now = false;
        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.text_edit_singleline(&mut self.open_project_path);
                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 open_now {
            let path = PathBuf::from(self.open_project_path.trim());
            match self.open_project(path) {
                Ok(()) => {
                    self.show_open_project = false;
                }
                Err(err) => {
                    self.status.say(format!("Open failed: {err}"));
                }
            }
        } else if cancel_now {
            self.show_open_project = false;
        } else {
            self.show_open_project = open;
        }
    }

    fn dock_contains(&self, kind: &PanelKind) -> bool {
        self.dock.iter_all_tabs().any(|(_, tab)| tab == kind)
    }

    fn toggle_panel(&mut self, kind: PanelKind) {
        // Existing tab: remove it. Missing tab: drop it into the currently-
        // focused dock leaf so the user gets it back somewhere visible.
        let path = self
            .dock
            .iter_all_tabs()
            .find_map(|(path, tab)| if *tab == kind { Some(path) } else { None });
        match path {
            Some(path) => {
                self.dock.remove_tab(path);
            }
            None => {
                self.dock.push_to_focused_leaf(kind);
            }
        }
    }

    fn invoke_menu_item(&mut self, path: &str) {
        let mut fired = false;
        for item in self.extensions.iter_menu_items_mut() {
            if item.path == path {
                (item.action)();
                fired = true;
                break;
            }
        }
        if fired {
            self.status.say(format!("Menu: {path}"));
        }
    }

    /// Selects a freshly spawned entity and syncs the inspector scratch buffers
    /// (rename field + euler cache) to it.
    fn select_spawned(&mut self, entity: Entity) {
        self.rename_buf = self.state.scene.name(entity).unwrap_or_default();
        self.state.selected = Some(entity);
        self.euler_for = None;
    }

    fn apply(&mut self, action: PendingAction) {
        match action {
            PendingAction::AddRootPrefab(name) => {
                // Disjoint field borrows: prefab_registry (read) + scene (write)
                // + registry (read) are separate fields of `state`.
                if let Some(e) = self.state.prefab_registry.spawn(
                    &name,
                    &mut self.state.scene,
                    &self.state.registry,
                ) {
                    self.select_spawned(e);
                }
            }
            PendingAction::AddChildPrefab(parent, name) => {
                if self.state.scene.contains(parent) {
                    if let Some(e) = self.state.prefab_registry.spawn_child(
                        &name,
                        parent,
                        &mut self.state.scene,
                        &self.state.registry,
                    ) {
                        self.select_spawned(e);
                    }
                }
            }
            PendingAction::Delete(entity) => {
                self.state.scene.despawn(entity, DespawnPolicy::Recursive);
                self.state.component_order.remove(&entity);
                if self.state.selected == Some(entity) {
                    self.state.selected = None;
                }
            }
            PendingAction::Duplicate(entity) => {
                if self.state.scene.contains(entity) {
                    let name = self.state.scene.name(entity).unwrap_or_default();
                    let transform = self.state.scene.local_transform(entity).unwrap_or_default();
                    let parent = self.state.scene.parent(entity);
                    let copy_name = format!("{name} copy");
                    let new = match parent {
                        Some(p) => self
                            .state
                            .scene
                            .spawn_child(p, copy_name.clone(), transform),
                        None => self.state.scene.spawn(copy_name.clone(), transform),
                    };
                    // Copy every registered modular component via whole-value
                    // RON round-trip. Essential (node-baked) components are
                    // already set by spawn — but Layer' mask isn't, so copy
                    // that too separately.
                    let comps: Vec<&'static str> = self
                        .state
                        .registry
                        .components_on(self.state.scene.world(), entity)
                        .into_iter()
                        .filter(|n| !is_essential_component(n) || *n == "Layer")
                        .collect();
                    for comp in comps {
                        if let Ok(ron) =
                            self.state
                                .registry
                                .get_ron(self.state.scene.world(), entity, comp)
                        {
                            let _ = self.state.registry.set_ron(
                                self.state.scene.world_mut(),
                                new,
                                comp,
                                &ron,
                            );
                        }
                    }
                    // Preserve the inspector order from the source so the copy
                    // shows its components in the same arrangement.
                    if let Some(order) = self.state.component_order.get(&entity).cloned() {
                        self.state.component_order.insert(new, order);
                    }
                    // Carry the per-component disable set along too —
                    // DisabledComponents isn't in the reflection registry, so
                    // the registry-driven copy above doesn't see it. Scope the
                    // immutable borrow so the world is freed before insert_one.
                    let dc: Option<DisabledComponents> = {
                        let world = self.state.scene.world();
                        world
                            .get::<&DisabledComponents>(entity)
                            .ok()
                            .map(|d| (*d).clone())
                    };
                    if let Some(dc) = dc {
                        let _ = self.state.scene.world_mut().insert_one(new, dc);
                    }
                    self.state.selected = Some(new);
                    self.rename_buf = copy_name;
                    self.euler_for = None;
                }
            }
            PendingAction::SetEnabled(entity, enabled) => {
                self.state.scene.set_enabled(entity, enabled);
            }
            PendingAction::RestoreAllBindings => {
                self.restore_default_bindings();
            }
            PendingAction::RestoreActionDefaults(action) => {
                self.state.actions.restore_defaults(&action);
                self.state.actions.restore_axis_defaults(&action);
                self.state.actions.restore_axis_2d_defaults(&action);
                self.state.sync_action_overrides_to_settings();
                self.bindings_dirty = true;
                self.status.say(format!("Restored '{action}' defaults"));
            }
            PendingAction::RemoveButtonBinding { action, index } => {
                let mut bindings = self.state.actions.bindings(&action).to_vec();
                if index < bindings.len() {
                    bindings.remove(index);
                    self.state.actions.set_bindings(&action, bindings);
                    self.state.sync_action_overrides_to_settings();
                    self.bindings_dirty = true;
                }
            }
            PendingAction::RemoveAxisBinding { action, dir, index } => match dir {
                AxisDirection::Axis(side) => {
                    if let Some(current) = self.state.actions.axis_bindings(&action).cloned() {
                        let mut next = current;
                        let list = side_list_mut(&mut next, side);
                        if index < list.len() {
                            list.remove(index);
                            self.state.actions.set_axis_bindings(&action, next);
                            self.state.sync_action_overrides_to_settings();
                            self.bindings_dirty = true;
                        }
                    }
                }
                AxisDirection::Axis2D(side) => {
                    if let Some(current) = self.state.actions.axis_2d_bindings(&action).cloned() {
                        let mut next = current;
                        let list = side_2d_list_mut(&mut next, side);
                        if index < list.len() {
                            list.remove(index);
                            self.state.actions.set_axis_2d_bindings(&action, next);
                            self.state.sync_action_overrides_to_settings();
                            self.bindings_dirty = true;
                        }
                    }
                }
            },
        }
    }
}

impl Default for Shell {
    fn default() -> Self {
        Self::new()
    }
}

// ---- tab viewer (renders each panel's content) -------------------------

/// Holds the mutable references each panel needs to render. Constructed
/// per-frame inside [`Shell::build`] so the borrow lives only for one
/// `DockArea::show` call.
struct ShellTabViewer<'a> {
    state: &'a mut EditorState,
    commands: &'a mut CommandStack<EditorState>,
    extensions: &'a mut EditorExtensions,
    pending: &'a mut Vec<PendingAction>,
    rename_buf: &'a mut String,
    /// The Console command-input buffer (the terminal prompt).
    terminal_input: &'a mut String,
    rot_euler: &'a mut Vec3,
    euler_for: &'a mut Option<(Entity, &'static str, &'static str)>,
    /// Set to `true` when the Layer dropdown's "Edit names…" entry is clicked.
    show_layer_editor: &'a mut bool,
    /// Set to `true` when the Groups dropdown's "Edit groups…" entry is clicked.
    show_group_editor: &'a mut bool,
    /// Filled when the Viewport tab renders, so the shell can answer
    /// `cursor_over_viewport` next frame.
    viewport_rect_px: &'a mut Option<(f32, f32, f32, f32)>,
    /// Per-frame projection data the Viewport tab's gizmo overlay paints
    /// with (set by the host before `Shell::build`). `None` skips the
    /// overlay this frame.
    viewport_overlay: Option<ViewportOverlay>,
    /// Physical-pixel scale for converting egui logical coords (points) to
    /// the pixel-space cursor coordinates the host uses.
    pixels_per_point: f32,
    /// When set, the Viewport tab paints collider wireframe gizmos (View ▸
    /// Show Colliders). Copied in from [`Shell::show_colliders`].
    show_colliders: bool,
    /// The raycast-probe result to paint this frame, if any (View ▸ Raycast
    /// Probe). Copied in from [`Shell::raycast_probe_viz`].
    raycast_probe_viz: Option<RaycastProbeViz>,
    /// The interactive PTY terminal sessions shown as tabs (the Terminal panel
    /// renders/drives them). Borrowed from [`Shell::terminals`].
    terminals: &'a mut Vec<crate::pty::PtyTerminal>,
    /// The active terminal tab index. Borrowed from [`Shell::active_terminal`].
    active_terminal: &'a mut usize,
}

impl<'a> egui_dock::TabViewer for ShellTabViewer<'a> {
    type Tab = PanelKind;

    fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
        tab.title().into()
    }

    fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
        match tab {
            PanelKind::Hierarchy => self.hierarchy(ui),
            PanelKind::Inspector => self.inspector(ui),
            // The 3D viewport is rendered to the full surface *before* egui
            // paints, so the Viewport tab is left empty and its background is
            // suppressed (see `clear_background` below) — the 3D content
            // shows through where the Viewport tab is, and the opaque
            // backgrounds of every other panel occlude the rest. The rect
            // is captured so the host knows where the cursor is interacting
            // with the 3D scene rather than an opaque panel.
            PanelKind::Viewport => {
                let r = ui.max_rect();
                let s = self.pixels_per_point;
                *self.viewport_rect_px = Some((r.min.x * s, r.min.y * s, r.max.x * s, r.max.y * s));
                if self.show_colliders {
                    self.paint_collider_gizmos(ui, r);
                }
                self.paint_raycast_probe(ui, r);
                self.paint_gizmo_overlay(ui, r);
                self.paint_play_indicator(ui, r);
            }
            PanelKind::Project => self.project_panel(ui),
            PanelKind::UiCanvas => self.ui_canvas_panel(ui),
            PanelKind::Console => self.console(ui),
            PanelKind::Terminal => self.terminal_panel(ui),
            PanelKind::Custom(name) => self.custom_panel(ui, name),
        }
    }

    fn clear_background(&self, tab: &Self::Tab) -> bool {
        // Transparent only for the 3D viewport tab; every other tab keeps its
        // background or it'd bleed onto the 3D scene behind.
        !matches!(tab, PanelKind::Viewport)
    }
}

impl<'a> ShellTabViewer<'a> {
    // --- Gizmo overlay rendering (Stage 7 piece 6c) -----------------------

    /// Paints the active gizmo's handles over the Viewport tab using egui's
    /// 2D painter — same convention every DCC tool uses for transform
    /// gizmos. Hit-testing is in 3D world space (see `crate::gizmo`); this
    /// is purely the visualization.
    /// Paints a play-state border + corner badge over the viewport so it is
    /// unmistakable when the scene is running vs. being authored — the editor's
    /// analogue of Unity's play-mode tint. No-op while editing.
    fn paint_play_indicator(&self, ui: &egui::Ui, tab_rect: egui::Rect) {
        let (color, label) = match self.state.play {
            PlayState::Playing => (egui::Color32::from_rgb(120, 220, 120), "PLAYING"),
            PlayState::Paused => (egui::Color32::from_rgb(235, 200, 90), "PAUSED"),
            PlayState::Editing => return,
        };
        let painter = ui.painter_at(tab_rect);
        // Inset by half the stroke width so the full border stays inside the
        // viewport rect rather than being clipped at the edges.
        stroke_rect(
            &painter,
            tab_rect.shrink(1.5),
            egui::Stroke::new(3.0, 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 } else { 1.2 }, 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, 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, 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, color));
        // Arrowhead at the tip — a small filled triangle perpendicular to
        // the line direction in screen space.
        let dir = (pb - pa).normalized();
        if dir.length_sq() > 0.0 {
            let perp = egui::vec2(-dir.y, dir.x);
            let base = pb - dir * 8.0;
            let p1 = base + perp * 4.0;
            let p2 = base - perp * 4.0;
            painter.add(egui::Shape::convex_polygon(
                vec![pb, p1, p2],
                color,
                egui::Stroke::NONE,
            ));
        }
    }

    fn draw_plane_quad(
        &self,
        painter: &egui::Painter,
        tab_rect: egui::Rect,
        overlay: &ViewportOverlay,
        origin: oxide_engine::math::Vec3,
        plane: PlaneAxis,
        engaged: bool,
    ) {
        let (a, b) = plane.axes();
        let s = overlay.gizmo_size;
        // The quad lives at 0.3..0.7 of the gizmo size along each in-plane
        // axis, matching what `gizmo::hit_test` claims for the same handle.
        let corners = [
            origin + a * (s * 0.3) + b * (s * 0.3),
            origin + a * (s * 0.7) + b * (s * 0.3),
            origin + a * (s * 0.7) + b * (s * 0.7),
            origin + a * (s * 0.3) + b * (s * 0.7),
        ];
        let projected: Vec<egui::Pos2> = corners
            .iter()
            .filter_map(|p| project(*p, &overlay.view_proj, tab_rect))
            .collect();
        if projected.len() != 4 {
            return;
        }
        let normal_axis = plane.normal();
        let color = if normal_axis == oxide_engine::math::Vec3::Z {
            egui::Color32::from_rgba_unmultiplied(80, 120, 255, if engaged { 220 } else { 130 })
        } else if normal_axis == oxide_engine::math::Vec3::Y {
            egui::Color32::from_rgba_unmultiplied(120, 255, 120, if engaged { 220 } else { 130 })
        } else {
            egui::Color32::from_rgba_unmultiplied(255, 120, 120, if engaged { 220 } else { 130 })
        };
        let stroke = egui::Stroke::new(1.5, color);
        painter.add(egui::Shape::convex_polygon(projected, color, stroke));
    }

    fn draw_axis_circle(
        &self,
        painter: &egui::Painter,
        tab_rect: egui::Rect,
        overlay: &ViewportOverlay,
        origin: oxide_engine::math::Vec3,
        axis: Axis3,
        engaged: bool,
    ) {
        // Pick two in-plane unit vectors orthogonal to the axis.
        let n = axis.unit();
        let (u, v) = orthonormal_basis(n);
        let r = overlay.gizmo_size;
        let segs = 48;
        let mut pts: Vec<egui::Pos2> = Vec::with_capacity(segs + 1);
        for i in 0..=segs {
            let t = (i as f32) * std::f32::consts::TAU / (segs as f32);
            let world = origin + u * (r * t.cos()) + v * (r * t.sin());
            if let Some(p) = project(world, &overlay.view_proj, tab_rect) {
                pts.push(p);
            }
        }
        if pts.len() < 2 {
            return;
        }
        let color = axis_color(axis, engaged);
        painter.add(egui::Shape::line(pts, egui::Stroke::new(2.0, color)));
    }

    fn hierarchy(&mut self, ui: &mut egui::Ui) {
        // Snapshot the prefab names once so the add-menus can list them without
        // borrowing the registry inside the UI closures.
        let prefab_names: Vec<String> = self
            .state
            .prefab_registry
            .names()
            .map(String::from)
            .collect();
        ui.horizontal(|ui| {
            ui.menu_button("➕ Root", |ui| {
                for name in &prefab_names {
                    if ui.button(name).clicked() {
                        self.pending
                            .push(PendingAction::AddRootPrefab(name.clone()));
                        ui.close();
                    }
                }
            });
            let has_sel = self.state.selected.is_some();
            ui.add_enabled_ui(has_sel, |ui| {
                ui.menu_button("➕ Child", |ui| {
                    if let Some(sel) = self.state.selected {
                        for name in &prefab_names {
                            if ui.button(name).clicked() {
                                self.pending
                                    .push(PendingAction::AddChildPrefab(sel, name.clone()));
                                ui.close();
                            }
                        }
                    }
                });
            });
            if ui
                .add_enabled(has_sel, egui::Button::new("🗑 Delete"))
                .clicked()
            {
                if let Some(sel) = self.state.selected {
                    self.pending.push(PendingAction::Delete(sel));
                }
            }
        });
        ui.separator();

        ui.weak("Drag a node to move it — nest by dropping on a node, reorder by dropping between nodes. Right-click for actions.");

        let rows = snapshot(&self.state.scene);
        let selected_entity = self.state.selected;

        // Drag state for the insertion indicator: which entity is being
        // dragged, where the pointer is, and whether it was released this frame.
        let dragged = egui::DragAndDrop::payload::<Entity>(ui.ctx()).map(|a| *a);
        let pointer = ui.ctx().pointer_interact_pos();
        let released = ui.input(|i| i.pointer.any_released());

        // Collect interactions into locals so the deeply-nested drag/menu
        // closures never borrow `self`; apply them after the ScrollArea.
        let mut queued: Vec<PendingAction> = Vec::new();
        let mut new_selection: Option<(Entity, String)> = None;
        // The drop chosen on release: (row index, zone) — `usize::MAX` row with
        // `RootEnd` means "append to the root level".
        let mut drop_action: Option<(usize, DropZone)> = None;

        egui::ScrollArea::vertical().show(ui, |ui| {
            if rows.is_empty() {
                ui.weak("(empty scene — right-click to add a root)");
            }

            for (i, row) in rows.iter().enumerate() {
                let row_resp = ui
                    .horizontal(|ui| {
                        ui.add_space(row.depth as f32 * 16.0);
                        let mut enabled = row.enabled;
                        if ui.checkbox(&mut enabled, "").changed() {
                            queued.push(PendingAction::SetEnabled(row.entity, enabled));
                        }
                        let label = if row.name.is_empty() {
                            "(unnamed)".to_owned()
                        } else {
                            row.name.clone()
                        };
                        // Grey the whole subtree when an ancestor is disabled,
                        // not just the node whose own flag is off.
                        let text = if row.effective_enabled {
                            egui::RichText::new(label)
                        } else {
                            egui::RichText::new(label).weak().italics()
                        };
                        let selected = selected_entity == Some(row.entity);
                        // One widget senses everything: click (select),
                        // secondary-click (context menu), AND drag (move). The
                        // earlier `dnd_drag_source` wrapper claimed the press
                        // for itself and the inner label never saw clicks, so
                        // selection and the right-click menu silently broke.
                        // Doing it as one click_and_drag widget keeps the
                        // press attributed to *this* response: a still
                        // press+release is a click, a press+move is a drag,
                        // and a secondary-click is the context menu — no
                        // ambiguity between layered widgets.
                        let label_resp = ui
                            .selectable_label(selected, text)
                            .interact(egui::Sense::click_and_drag());
                        if label_resp.drag_started() {
                            egui::DragAndDrop::set_payload(ui.ctx(), row.entity);
                        }
                        if label_resp.dragged() {
                            ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
                        }
                        if label_resp.clicked() {
                            new_selection = Some((row.entity, row.name.clone()));
                        }
                        label_resp.context_menu(|ui| {
                            ui.menu_button("➕ Add Child", |ui| {
                                for name in &prefab_names {
                                    if ui.button(name).clicked() {
                                        queued.push(PendingAction::AddChildPrefab(
                                            row.entity,
                                            name.clone(),
                                        ));
                                        ui.close();
                                    }
                                }
                            });
                            if ui.button("Duplicate").clicked() {
                                queued.push(PendingAction::Duplicate(row.entity));
                                ui.close();
                            }
                            ui.separator();
                            if ui.button("🗑 Delete").clicked() {
                                queued.push(PendingAction::Delete(row.entity));
                                ui.close();
                            }
                        });
                    })
                    .response;

                // Insertion indicator: while dragging some *other* node over
                // this row, split it into before / nest / after zones, draw the
                // hint, and capture the drop on release.
                if let (Some(drag_e), Some(pos)) = (dragged, pointer) {
                    let r = row_resp.rect;
                    if drag_e != row.entity && r.y_range().contains(pos.y) && pos.x >= r.left() {
                        let t = (pos.y - r.top()) / r.height().max(1.0);
                        let zone = if t < 0.25 {
                            DropZone::Before
                        } else if t > 0.75 {
                            DropZone::After
                        } else {
                            DropZone::Child
                        };
                        let accent = ui.visuals().selection.bg_fill;
                        let line = egui::Stroke::new(2.0, accent);
                        match zone {
                            DropZone::Before => {
                                ui.painter().line_segment(
                                    [
                                        egui::pos2(r.left(), r.top()),
                                        egui::pos2(r.right(), r.top()),
                                    ],
                                    line,
                                );
                            }
                            DropZone::After => {
                                ui.painter().line_segment(
                                    [
                                        egui::pos2(r.left(), r.bottom()),
                                        egui::pos2(r.right(), r.bottom()),
                                    ],
                                    line,
                                );
                            }
                            DropZone::Child => {
                                let fill = egui::Color32::from_rgba_unmultiplied(
                                    accent.r(),
                                    accent.g(),
                                    accent.b(),
                                    60,
                                );
                                ui.painter().rect_filled(r, 2.0, fill);
                            }
                            DropZone::RootEnd => {}
                        }
                        if released {
                            drop_action = Some((i, zone));
                        }
                    }
                }
            }

            // Empty area below the rows: right-click to add a root, or drop a
            // dragged node here to move it to the end of the root level.
            let empty = ui.allocate_response(ui.available_size(), egui::Sense::click());
            empty.context_menu(|ui| {
                ui.menu_button("➕ Add Root", |ui| {
                    for name in &prefab_names {
                        if ui.button(name).clicked() {
                            queued.push(PendingAction::AddRootPrefab(name.clone()));
                            ui.close();
                        }
                    }
                });
            });
            if let (Some(_), Some(pos)) = (dragged, pointer) {
                if released && empty.rect.contains(pos) {
                    drop_action = Some((usize::MAX, DropZone::RootEnd));
                }
            }
        });

        // Resolve the chosen drop into a single reorder (parent + before).
        if let (Some(drag_e), Some((i, zone))) = (dragged, drop_action) {
            let (new_parent, before) = match zone {
                DropZone::RootEnd => (None, None),
                DropZone::Child => (Some(rows[i].entity), None),
                DropZone::Before => (
                    self.state.scene.parent(rows[i].entity),
                    Some(rows[i].entity),
                ),
                DropZone::After => {
                    let target = rows[i].entity;
                    let parent = self.state.scene.parent(target);
                    let siblings: Vec<Entity> = match parent {
                        Some(p) => self.state.scene.children(p).to_vec(),
                        None => self.state.scene.roots().to_vec(),
                    };
                    let before = siblings
                        .iter()
                        .position(|&e| e == target)
                        .and_then(|idx| siblings.get(idx + 1).copied());
                    (parent, before)
                }
            };
            if let Err(err) = self.state.scene.reorder(drag_e, new_parent, before) {
                log::debug!("reorder rejected: {err}");
            }
        }

        if let Some((entity, name)) = new_selection {
            self.state.selected = Some(entity);
            *self.rename_buf = name;
            *self.euler_for = None;
        }
        self.pending.extend(queued);
    }

    fn inspector(&mut self, ui: &mut egui::Ui) {
        let Some(selected) = self.state.selected else {
            ui.weak("Select a node to inspect it.");
            return;
        };
        if !self.state.scene.contains(selected) {
            self.state.selected = None;
            return;
        }

        ui.horizontal(|ui| {
            ui.label("Name");
            let edited = ui.text_edit_singleline(self.rename_buf);
            if edited.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
                let cmd = RenameCmd::new(self.state, selected, self.rename_buf.clone());
                self.commands.push(cmd, self.state);
            }
        });

        let mut enabled = self.state.scene.is_enabled(selected).unwrap_or(true);
        if ui.checkbox(&mut enabled, "Enabled").changed() {
            self.pending
                .push(PendingAction::SetEnabled(selected, enabled));
        }

        // Parent is shown read-only here — reparenting is done by drag-and-drop
        // in the Hierarchy panel (a dropdown listing every entity doesn't scale
        // to large scenes).
        let parent_label = match self.state.scene.parent(selected) {
            None => "(root)".to_owned(),
            Some(p) => self
                .state
                .scene
                .name(p)
                .unwrap_or_else(|| "(unnamed)".into()),
        };
        ui.horizontal(|ui| {
            ui.label("Parent");
            ui.weak(parent_label);
        });

        // Two sections: **node-baked** components (Layer + Transform — always
        // present on every entity, single-instance, no controls), then
        // **modular** components (the user's choice: drag-reorder, disable,
        // remove, add).
        self.essential_components(ui, selected);
        self.reflected_components(ui, selected);

        ui.separator();
        self.add_component_menu(ui, selected);
    }

    /// Renders the node-baked components in their fixed canonical order. These
    /// are inherent to every entity — auto-attached on `Scene::spawn` — so
    /// they don't carry the drag handle / enable checkbox / remove button that
    /// modular components do.
    ///
    /// Layer is shown above Transform per the maintainer's preference: "which
    /// world is this entity in?" precedes "where in that world is it?". Node's
    /// own fields are already shown by the Name / Enabled header above, so it
    /// isn't repeated here.
    fn essential_components(&mut self, ui: &mut egui::Ui, selected: Entity) {
        ui.separator();
        ui.heading("Layer");
        self.layers_widget(ui, selected);

        ui.separator();
        ui.heading("Groups");
        self.groups_widget(ui, selected);

        ui.separator();
        ui.heading("Transform");
        self.essential_fields(ui, selected, "Transform");
    }

    /// Renders one essential component's reflected fields with no extras —
    /// no checkbox, no remove, no drag. Edits route through `SetFieldCmd`
    /// like modular fields do.
    fn essential_fields(&mut self, ui: &mut egui::Ui, selected: Entity, type_name: &'static str) {
        // Snapshot the field rows up front so we can drop the registry/world
        // borrow before calling field_widget (which borrows &mut self for the
        // euler buffer).
        struct Row {
            info: FieldInfo,
            variants: Option<&'static [&'static str]>,
            value: String,
        }
        let rows: Vec<Row> = {
            let world = self.state.scene.world();
            let Ok(infos) = self.state.registry.field_infos(world, selected, type_name) else {
                return;
            };
            infos
                .iter()
                .filter_map(|info| {
                    self.state
                        .registry
                        .get_field(world, selected, type_name, info.name)
                        .ok()
                        .map(|value| Row {
                            info: *info,
                            variants: self.state.registry.enum_variants(info.type_name),
                            value,
                        })
                })
                .collect()
        };

        let mut edits: Vec<(&'static str, String)> = Vec::new();
        // Namespace by component so a field name shared with another component
        // can't produce a duplicate egui widget id (see the modular loop).
        ui.push_id(type_name, |ui| {
            for row in &rows {
                if let Some(new_ron) =
                    self.field_widget(ui, selected, type_name, &row.info, row.variants, &row.value)
                {
                    edits.push((row.info.name, new_ron));
                }
            }
        });
        for (field, ron) in edits {
            if let Some(cmd) = SetFieldCmd::new(self.state, selected, type_name, field, ron) {
                self.commands.push(cmd, self.state);
            }
        }
    }

    /// The `Layer` component widget: a single-choice dropdown of layer names.
    ///
    /// Each entity belongs to exactly one layer (the Unity model — per-entity
    /// membership is single, filters are masks). The dropdown shows every
    /// named layer from [`LayerRegistry`](oxide_engine::layer::LayerRegistry)
    /// plus a `"Layer N"` fallback for unnamed slots, and an
    /// **"Edit names…"** entry that opens the layer-editor modal so a project
    /// can define names like `"Player"`, `"Enemy"`, `"World"`.
    fn layers_widget(&mut self, ui: &mut egui::Ui, selected: Entity) {
        use oxide_engine::layer::{Layer, MAX_LAYERS};
        let current_index: u32 = match self.state.scene.world().get::<&Layer>(selected) {
            Ok(l) => l.index,
            Err(_) => {
                ui.weak("(no Layer — internal error: should be auto-attached)");
                return;
            }
        };
        let label_for = |i: u32, reg: &oxide_engine::layer::LayerRegistry| -> String {
            reg.name(i)
                .map(String::from)
                .unwrap_or_else(|| format!("Layer {i}"))
        };
        let current_label = label_for(current_index, &self.state.layer_registry);

        let mut chosen: Option<u32> = None;
        let mut open_editor = false;
        ui.horizontal(|ui| {
            ui.label("Layer");
            egui::ComboBox::from_id_salt(("oxide.layer.dropdown", selected))
                .selected_text(current_label)
                .show_ui(ui, |ui| {
                    for i in 0..MAX_LAYERS {
                        let name = label_for(i, &self.state.layer_registry);
                        if ui.selectable_label(i == current_index, name).clicked() {
                            chosen = Some(i);
                        }
                    }
                    ui.separator();
                    if ui.button("✏ Edit names…").clicked() {
                        open_editor = true;
                    }
                });
        });

        if let Some(new_index) = chosen {
            if new_index != current_index {
                let new = Layer::on(new_index);
                if let Ok(ron) = ron::to_string(&new_index) {
                    if let Some(cmd) = SetFieldCmd::new(self.state, selected, "Layer", "index", ron)
                    {
                        self.commands.push(cmd, self.state);
                    } else {
                        // Fall back to a direct mutation if the registry path
                        // somehow refuses (shouldn't happen for the auto-
                        // attached Layer component).
                        if let Ok(mut l) = self.state.scene.world_mut().get::<&mut Layer>(selected)
                        {
                            *l = new;
                        }
                    }
                }
            }
        }
        if open_editor {
            *self.show_layer_editor = true;
        }
    }

    /// The `Groups` widget: a **multi-select** dropdown of the project's defined
    /// group names, each a checkbox toggling this entity's membership.
    ///
    /// Groups are the multi-valued counterpart to the single-select `Layer`
    /// above — an entity is on one layer but in any number of groups. Membership
    /// lives in the entity's [`Tags`](oxide_engine::layer::Tags) component
    /// (created lazily on first add); the dropdown's vocabulary comes from
    /// [`group_registry`](EditorState::group_registry). An **"Edit groups…"**
    /// entry opens the Groups editor to define new groups. Any tag an entity
    /// carries that is *not* a defined group is surfaced below as an
    /// "ungrouped tag" so it stays visible (e.g. one a script set, or a group
    /// later deleted from the project).
    fn groups_widget(&mut self, ui: &mut egui::Ui, selected: Entity) {
        use oxide_engine::layer::Tags;

        // Snapshot the entity's current group membership and the project's
        // defined groups, so the egui closures don't borrow the world/registry
        // while we also need &mut self for the mutation below.
        let current: Vec<String> = self
            .state
            .scene
            .world()
            .get::<&Tags>(selected)
            .map(|t| t.iter().map(String::from).collect())
            .unwrap_or_default();
        let defined: Vec<String> = self.state.group_registry.iter().map(String::from).collect();
        // Defined groups the node isn't in yet — the "add" menu's contents.
        let available: Vec<String> = defined
            .iter()
            .filter(|d| !current.iter().any(|c| c == *d))
            .cloned()
            .collect();

        // (group name, now a member?) — applied after the UI closures so the
        // world isn't borrowed while egui is mid-render.
        let mut change: Option<(String, bool)> = None;
        let mut open_editor = false;

        ui.horizontal(|ui| {
            ui.label("Groups");
            // "＋" opens a menu of defined groups not yet on this node.
            ui.menu_button("➕", |ui| {
                if available.is_empty() {
                    if defined.is_empty() {
                        ui.weak("(no groups defined)");
                    } else {
                        ui.weak("(already in every group)");
                    }
                }
                for name in &available {
                    if ui.button(name).clicked() {
                        change = Some((name.clone(), true));
                        ui.close();
                    }
                }
                ui.separator();
                if ui.button("✏ Edit groups…").clicked() {
                    open_editor = true;
                    ui.close();
                }
            });
        });

        // Each group the node is in renders as its own removable row — so a
        // group can always be removed, including one whose definition was later
        // deleted from the project (shown as "(undefined)" but still removable).
        if current.is_empty() {
            ui.weak("(not in any group)");
        }
        for name in &current {
            ui.horizontal(|ui| {
                if ui
                    .small_button("🗑")
                    .on_hover_text("Remove from this group")
                    .clicked()
                {
                    change = Some((name.clone(), false));
                }
                ui.label(name);
                if !defined.iter().any(|d| d == name) {
                    ui.weak("(undefined)");
                }
            });
        }

        if let Some((name, member)) = change {
            apply_group_membership(self.state.scene.world_mut(), selected, &name, member);
        }
        if open_editor {
            *self.show_group_editor = true;
        }
    }

    /// "Add Component ▾" menu. Lists every addable type registered in the
    /// reflection registry. For each:
    ///
    /// - If the entity **doesn't** already carry it → "*Name*" attaches it.
    /// - If it **does** → "*Name* (as child)" spawns a new child entity with
    ///   the component already attached and selects it. This is Oxide's
    ///   answer to "multiple of the same type": archetypal ECS allows one
    ///   per type per entity, so a second mesh / collider / etc. lives on a
    ///   child entity (Bevy's pattern). The user still gets the workflow
    ///   from a single menu click.
    fn add_component_menu(&mut self, ui: &mut egui::Ui, selected: Entity) {
        enum AddChoice {
            HereIfAbsent(&'static str),
            AsChild(&'static str),
        }

        // Snapshot per-type presence before opening the menu so the closure
        // doesn't have to borrow self twice.
        let present_set: Vec<(&'static str, bool)> = {
            let world = self.state.scene.world();
            self.state
                .registry
                .addable_names()
                .map(|n| {
                    (
                        n,
                        self.state.registry.has(world, selected, n).unwrap_or(false),
                    )
                })
                .collect()
        };

        let mut chosen: Option<AddChoice> = None;
        ui.menu_button("➕ Add Component ▾", |ui| {
            if present_set.is_empty() {
                ui.weak("(no addable components registered)");
            }
            for (name, present) in &present_set {
                if !present {
                    if ui.button(*name).clicked() {
                        chosen = Some(AddChoice::HereIfAbsent(name));
                        ui.close();
                    }
                } else {
                    // Already present: offer it as a child instead, so the
                    // user can still "add another mesh / collider / …" from
                    // one menu without breaking the ECS one-per-type rule.
                    if ui
                        .button(format!("{name} (as child)"))
                        .on_hover_text("Spawns a new child entity carrying this component")
                        .clicked()
                    {
                        chosen = Some(AddChoice::AsChild(name));
                        ui.close();
                    }
                }
            }
        });

        match chosen {
            None => {}
            Some(AddChoice::HereIfAbsent(name)) => {
                let added = matches!(
                    self.state
                        .registry
                        .add_default(self.state.scene.world_mut(), selected, name),
                    Ok(true)
                );
                if added {
                    self.state
                        .component_order
                        .entry(selected)
                        .or_default()
                        .push(name);
                }
            }
            Some(AddChoice::AsChild(name)) => {
                // Spawn a child entity with the component attached and select
                // it. Name reflects the type so the hierarchy shows what it is.
                let child_name = name.to_string();
                let child =
                    self.state
                        .scene
                        .spawn_child(selected, child_name.clone(), Transform::IDENTITY);
                let _ = self
                    .state
                    .registry
                    .add_default(self.state.scene.world_mut(), child, name);
                self.state
                    .component_order
                    .entry(child)
                    .or_default()
                    .push(name);
                self.state.selected = Some(child);
                *self.rename_buf = child_name;
                *self.euler_for = None;
            }
        }
    }

    /// Renders every *reflected* component on the entity by walking the
    /// reflection registry — one heading per component, one widget per field —
    /// instead of a hand-written panel per type. This is what lets a brand-new
    /// component type appear in the inspector with no editor code: derive
    /// `Reflect`, register it, done.
    ///
    /// `Node` is skipped because its fields (`name`, `enabled`) are already
    /// shown by the bespoke header above.
    fn reflected_components(&mut self, ui: &mut egui::Ui, selected: Entity) {
        /// One field's descriptor + its current value as RON, plus the enum
        /// variant list when the field's type is a registered enum (so phase 2
        /// can render a dropdown). Snapshotted so we don't hold a registry/world
        /// borrow across the egui render + the command push that follows.
        struct FieldRow {
            info: FieldInfo,
            variants: Option<&'static [&'static str]>,
            value: String,
        }
        struct CompRows {
            name: &'static str,
            /// Whether the component is currently in the entity's
            /// `DisabledComponents` set — drives the per-component checkbox.
            disabled: bool,
            fields: Vec<FieldRow>,
        }

        // Phase 1: gather metadata + current values (immutable borrows only).
        let mut comps: Vec<CompRows> = Vec::new();
        {
            let world = self.state.scene.world();
            let present: Vec<&'static str> = self
                .state
                .registry
                .components_on(world, selected)
                .into_iter()
                .filter(|n| !is_essential_component(n))
                .collect();
            let saved = self
                .state
                .component_order
                .get(&selected)
                .map(Vec::as_slice)
                .unwrap_or(&[]);
            for name in inspector_component_order(&present, saved) {
                let Ok(infos) = self.state.registry.field_infos(world, selected, name) else {
                    continue;
                };
                let fields = infos
                    .iter()
                    .filter_map(|info| {
                        self.state
                            .registry
                            .get_field(world, selected, name, info.name)
                            .ok()
                            .map(|value| FieldRow {
                                info: *info,
                                variants: self.state.registry.enum_variants(info.type_name),
                                value,
                            })
                    })
                    .collect();
                let disabled = self.state.scene.is_component_disabled(selected, name);
                comps.push(CompRows {
                    name,
                    disabled,
                    fields,
                });
            }
        }

        // Phase 2: render, collecting field edits + removals + per-component
        // disable toggles + drag-reorder. The euler buffer for quat fields
        // needs &mut self, so this can't hold the phase-1 borrows.
        let mut edits: Vec<(&'static str, &'static str, String)> = Vec::new();
        let mut remove: Option<&'static str> = None;
        let mut toggle: Option<(&'static str, bool)> = None;

        // Drag-reorder state: payload is the component name being dragged,
        // pointer + released drive the drop decision.
        let dragged = egui::DragAndDrop::payload::<&'static str>(ui.ctx()).map(|a| *a);
        let pointer = ui.ctx().pointer_interact_pos();
        let released = ui.input(|i| i.pointer.any_released());
        // (dragged, target, place_before) on release.
        let mut reorder: Option<(&'static str, &'static str, bool)> = None;

        for comp in &comps {
            ui.separator();
            let row_resp = ui
                .horizontal(|ui| {
                    // Enable/disable checkbox.
                    let mut enabled = !comp.disabled;
                    if ui
                        .checkbox(&mut enabled, "")
                        .on_hover_text("Enable / disable this component")
                        .changed()
                    {
                        toggle = Some((comp.name, !enabled));
                    }
                    // Visually dim a disabled component's heading so it's clear
                    // systems will skip it. (No leading grip glyph — the hover
                    // Grab cursor + tooltip signal the drag affordance, and the
                    // braille grip rendered as a tofu box in egui's font.)
                    let title = if comp.disabled {
                        egui::RichText::new(comp.name).heading().weak().italics()
                    } else {
                        egui::RichText::new(comp.name).heading().strong()
                    };
                    // The title **is** the drag handle. It must be a
                    // *non-selectable* Label: `ui.heading()` builds a selectable
                    // label, so a press-drag highlighted the text instead of
                    // starting a drag. `Label::selectable(false)` + a
                    // click-and-drag sense fixes that while keeping a hit rect
                    // big enough for egui's drag detection to fire.
                    let heading = ui
                        .add(
                            egui::Label::new(title)
                                .selectable(false)
                                .sense(egui::Sense::click_and_drag()),
                        )
                        .on_hover_cursor(egui::CursorIcon::Grab)
                        .on_hover_text("Drag the title to reorder this component");
                    if heading.drag_started() {
                        egui::DragAndDrop::set_payload(ui.ctx(), comp.name);
                    }
                    if heading.dragged() {
                        ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
                    }
                    if ui
                        .small_button("🗑")
                        .on_hover_text("Remove component")
                        .clicked()
                    {
                        remove = Some(comp.name);
                    }
                })
                .response;

            // Drop indicator + capture for drag-reorder. Transform is never a
            // valid drop target (it's pinned first); a component never drops
            // onto itself.
            if let (Some(drag_name), Some(pos)) = (dragged, pointer) {
                if drag_name != comp.name && comp.name != "Transform" {
                    let r = row_resp.rect;
                    if r.contains(pos) {
                        let above = (pos.y - r.top()) < r.height() / 2.0;
                        let accent = ui.visuals().selection.bg_fill;
                        let line = egui::Stroke::new(2.0, 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));
                    }
                }
            });
        }

        // 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;
        }
    }

    /// Renders one reflected field as a typed widget chosen from its
    /// `type_name`, returning the field's new RON if the user changed it.
    /// Unknown types fall back to an editable RON text box, so the inspector is
    /// fully generic even for types it has no bespoke widget for.
    fn field_widget(
        &mut self,
        ui: &mut egui::Ui,
        entity: Entity,
        type_name: &'static str,
        info: &FieldInfo,
        variants: Option<&'static [&'static str]>,
        current: &str,
    ) -> Option<String> {
        // Enum-typed fields with a registered variant list become a dropdown,
        // regardless of the syntactic type_name. The chosen name is already
        // valid RON for the unit variant (see ReflectEnum docs).
        if let Some(variants) = variants {
            let mut current_owned = current.to_owned();
            let mut chosen: Option<String> = None;
            ui.horizontal(|ui| {
                ui.label(info.name);
                egui::ComboBox::from_id_salt(("oxide.field.enum", entity, info.name))
                    .selected_text(&current_owned)
                    .show_ui(ui, |ui| {
                        for v in variants {
                            if ui.selectable_label(current_owned == *v, *v).clicked() {
                                current_owned = (*v).to_owned();
                                chosen = Some((*v).to_owned());
                            }
                        }
                    });
            });
            return chosen;
        }
        // Asset-reference fields (`AssetRef<T>` / `Handle<T>`) get a picker that
        // lists the project's assets of the matching kind, filtered by the
        // field's target type. Recognised by the field's syntactic type name.
        if let Some(target) = asset_ref_target(info.type_name) {
            return self.asset_ref_field_widget(ui, entity, info, target, current);
        }
        match info.type_name {
            "f32" => {
                let mut v: f32 = ron::from_str(current).ok()?;
                let changed = ui
                    .horizontal(|ui| {
                        ui.label(info.name);
                        // `#[reflect(min, max)]` → slider; otherwise unbounded drag.
                        if let Some((min, max)) = info.range {
                            ui.add(egui::Slider::new(&mut v, min..=max)).changed()
                        } else {
                            ui.add(egui::DragValue::new(&mut v).speed(0.05)).changed()
                        }
                    })
                    .inner;
                changed.then(|| ron::to_string(&v).ok()).flatten()
            }
            "Color" => {
                let c: Color = ron::from_str(current).ok()?;
                let mut rgba = [c.r, c.g, c.b, c.a];
                let changed = ui
                    .horizontal(|ui| {
                        ui.label(info.name);
                        ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed()
                    })
                    .inner;
                if changed {
                    ron::to_string(&Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3])).ok()
                } else {
                    None
                }
            }
            "Material" => {
                // The basic PBR material renders as one composite block: a
                // color picker for albedo + sliders for the normalized
                // metallic / roughness factors — the widgets the user expects
                // for a surface, instead of a raw RON string. (A future
                // recursive struct inspector based on `Reflect` would replace
                // this special case generically.)
                let mut m: Material = ron::from_str(current).ok()?;
                let mut changed = false;
                ui.label(info.name);
                ui.indent(("oxide.field.material", entity, info.name), |ui| {
                    let mut rgba = [m.albedo.r, m.albedo.g, m.albedo.b, m.albedo.a];
                    ui.horizontal(|ui| {
                        ui.label("albedo");
                        if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
                            m.albedo = Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
                            changed = true;
                        }
                    });
                    ui.horizontal(|ui| {
                        ui.label("metallic");
                        if ui
                            .add(egui::Slider::new(&mut m.metallic, 0.0..=1.0))
                            .changed()
                        {
                            changed = true;
                        }
                    });
                    ui.horizontal(|ui| {
                        ui.label("roughness");
                        if ui
                            .add(egui::Slider::new(&mut m.roughness, 0.0..=1.0))
                            .changed()
                        {
                            changed = true;
                        }
                    });
                });
                if changed {
                    ron::to_string(&m).ok()
                } else {
                    None
                }
            }
            "bool" => {
                let mut v: bool = ron::from_str(current).ok()?;
                let changed = ui
                    .horizontal(|ui| {
                        ui.label(info.name);
                        ui.checkbox(&mut v, "").changed()
                    })
                    .inner;
                changed.then(|| v.to_string())
            }
            "Vec3" => {
                let mut v: Vec3 = ron::from_str(current).ok()?;
                vec3_drag(ui, info.name, &mut v, 0.05)
                    .then(|| ron::to_string(&v).ok())
                    .flatten()
            }
            "Quat" => self.quat_field_widget(ui, entity, type_name, info, current),
            "String" => {
                let mut s: String = ron::from_str(current).ok()?;
                let changed = ui
                    .horizontal(|ui| {
                        ui.label(info.name);
                        ui.text_edit_singleline(&mut s).changed()
                    })
                    .inner;
                changed.then(|| ron::to_string(&s).ok()).flatten()
            }
            "LayerMask" => {
                // A multi-select dropdown of named layers — the right control
                // for a *filter* mask (e.g. a Camera's visibility), which is
                // multi-valued even though an entity's own `Layer` is single.
                let mut mask: LayerMask = ron::from_str(current).ok()?;
                // Snapshot labels so the combo closure doesn't borrow the
                // registry while we mutate `mask`.
                let labels: Vec<(u32, String)> = (0..oxide_engine::layer::MAX_LAYERS)
                    .map(|i| (i, layer_label(i, &self.state.layer_registry)))
                    .collect();
                let summary = mask_summary(mask, &self.state.layer_registry);
                let mut changed = false;
                ui.horizontal(|ui| {
                    ui.label(info.name);
                    egui::ComboBox::from_id_salt(("oxide.field.layermask", entity, info.name))
                        .selected_text(summary)
                        .show_ui(ui, |ui| {
                            for (i, name) in &labels {
                                let mut on = mask.contains_layer(*i);
                                if ui.checkbox(&mut on, name).changed() {
                                    mask = mask.toggled(*i);
                                    changed = true;
                                }
                            }
                        });
                });
                changed.then(|| ron::to_string(&mask).ok()).flatten()
            }
            // Fallback: edit the raw RON. A bad edit is simply rejected by
            // `set_field` (the value stays unchanged), so this is safe.
            _ => {
                let mut text = current.to_owned();
                let changed = ui
                    .horizontal(|ui| {
                        ui.label(info.name);
                        ui.text_edit_singleline(&mut text).changed()
                    })
                    .inner;
                changed.then_some(text)
            }
        }
    }

    /// Renders an asset-reference field as a picker: a dropdown of the project's
    /// assets whose [`AssetKind`] matches the field's `target` type, plus a
    /// "(none)" entry to clear it. The field's stored value is the asset's
    /// stable `AssetUid` (an `AssetRef<T>` serializes as `Option<AssetUid>`), so
    /// the returned RON is `Some((uid))` / `None` — independent of the project's
    /// location on disk.
    fn asset_ref_field_widget(
        &self,
        ui: &mut egui::Ui,
        entity: Entity,
        info: &FieldInfo,
        target: &str,
        current: &str,
    ) -> Option<String> {
        // The current value, if any. A malformed value is treated as empty.
        let cur: Option<AssetUid> = ron::from_str(current).unwrap_or(None);
        let filter = AssetKind::for_handle_target(target);

        let Some(db) = &self.state.asset_db else {
            ui.horizontal(|ui| {
                ui.label(info.name);
                ui.weak("(open a project to pick assets)");
            });
            return None;
        };

        // Snapshot the pickable assets (uid + leaf name) so the combo closure
        // doesn't borrow `db` while we mutate the selection.
        let mut options: Vec<(AssetUid, String)> = db
            .entries()
            .filter(|e| filter.map_or(true, |k| e.kind == k))
            .map(|e| {
                let leaf = e.path.rsplit('/').next().unwrap_or(&e.path).to_owned();
                (e.uid, leaf)
            })
            .collect();
        options.sort_by(|a, b| a.1.cmp(&b.1));

        let selected_text = match cur {
            Some(uid) => db
                .relative_path(uid)
                .map(|p| p.rsplit('/').next().unwrap_or(p).to_owned())
                .unwrap_or_else(|| "(missing)".to_owned()),
            None => "(none)".to_owned(),
        };

        let mut chosen: Option<Option<AssetUid>> = None;
        ui.horizontal(|ui| {
            ui.label(info.name);
            egui::ComboBox::from_id_salt(("oxide.field.assetref", entity, info.name))
                .selected_text(selected_text)
                .show_ui(ui, |ui| {
                    if ui.selectable_label(cur.is_none(), "(none)").clicked() {
                        chosen = Some(None);
                    }
                    for (uid, leaf) in &options {
                        if ui.selectable_label(cur == Some(*uid), leaf).clicked() {
                            chosen = Some(Some(*uid));
                        }
                    }
                });
        });
        chosen.and_then(|value| ron::to_string(&value).ok())
    }

    /// Edits a `Quat` field as Euler degrees — raw quaternion components are
    /// not hand-editable. Uses the [`euler_for`](Shell::euler_for) buffer so
    /// the displayed angles stay stable across frames instead of jumping from
    /// lossy quat↔euler round-trips.
    fn quat_field_widget(
        &mut self,
        ui: &mut egui::Ui,
        entity: Entity,
        type_name: &'static str,
        info: &FieldInfo,
        current: &str,
    ) -> Option<String> {
        let q: Quat = ron::from_str(current).ok()?;
        let key = (entity, type_name, info.name);
        // Re-sync the buffer from the stored quaternion only when we switch to
        // a different field, so an in-progress edit isn't perturbed.
        if *self.euler_for != Some(key) {
            let (rx, ry, rz) = q.to_euler(EulerRot::XYZ);
            *self.rot_euler = Vec3::new(rx.to_degrees(), ry.to_degrees(), rz.to_degrees());
            *self.euler_for = Some(key);
        }
        let mut euler = *self.rot_euler;
        if vec3_drag(ui, info.name, &mut euler, 0.5) {
            *self.rot_euler = euler;
            let rotated = Quat::from_euler(
                EulerRot::XYZ,
                euler.x.to_radians(),
                euler.y.to_radians(),
                euler.z.to_radians(),
            );
            ron::to_string(&rotated).ok()
        } else {
            None
        }
    }

    fn project_panel(&mut self, ui: &mut egui::Ui) {
        let Some(project) = &self.state.project else {
            ui.weak("No project open.");
            ui.label("Open or create one from the File menu.");
            return;
        };
        ui.heading(project.name());
        ui.monospace(project.root().display().to_string());
        ui.separator();

        // Asset browser: typed folders grouped by kind, listed from the
        // database (the source of truth for what the picker can reference).
        // "Importing" an asset is just dropping the file into the right folder;
        // the file watcher rescans, or the user can rescan on demand.
        ui.horizontal(|ui| {
            ui.label("Assets");
            if ui
                .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();
                }
            }
        });
        match &self.state.asset_db {
            Some(db) => asset_browser(ui, db),
            None => {
                ui.weak("(asset database unavailable)");
            }
        }

        ui.separator();
        let project = self.state.project.as_ref().unwrap();
        show_project_tree(ui, "scenes/", project.scenes_dir());
        show_project_tree(ui, "scripts/", project.scripts_dir());
    }

    /// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview,
    /// and a property panel (including the font-asset picker) — saved as a
    /// `ui/` asset. Structural and property edits route through
    /// [`SetUiPanelCmd`](crate::commands::SetUiPanelCmd) so they undo.
    fn ui_canvas_panel(&mut self, ui: &mut egui::Ui) {
        if self.state.ui_doc.is_none() {
            ui.weak("No UI document open.");
            if ui.button("➕ New UI Document").clicked() {
                self.state.ui_doc = Some(crate::state::UiDoc::new());
            }
            self.ui_canvas_open_list(ui);
            return;
        }

        // Render from clones so we can mutate state / push commands afterwards
        // without holding a borrow of `self.state.ui_doc`.
        let doc = self.state.ui_doc.as_ref().unwrap();
        let panel = doc.panel.clone();
        let selected = doc.selected.clone();
        let dirty = doc.dirty;

        let mut new_selection: Option<WidgetPath> = None;
        let mut new_panel: Option<(UiPanel, String)> = None;
        let mut do_save = false;
        let mut do_close = false;

        // --- Toolbar --------------------------------------------------------
        ui.horizontal(|ui| {
            if ui.button("💾 Save").clicked() {
                do_save = true;
            }
            // Add a widget as a child of the selection (if it's a container),
            // else as a child of the root.
            ui.menu_button("➕ Add ▾", |ui| {
                for name in ["Leaf", "Row", "Column", "Grid", "Anchor"] {
                    if ui.button(name).clicked() {
                        let widget = match name {
                            "Row" => Widget::row(),
                            "Column" => Widget::column(),
                            "Grid" => Widget::grid(2, 2),
                            "Anchor" => Widget::anchor(),
                            _ => Widget::leaf(Vec2::new(120.0, 32.0)),
                        };
                        let parent = if panel
                            .root
                            .get_path(&selected)
                            .is_some_and(Widget::is_container)
                        {
                            selected.clone()
                        } else {
                            WidgetPath::root()
                        };
                        let mut next = panel.clone();
                        if next.root.push_child_at(&parent, widget) {
                            let count = next
                                .root
                                .get_path(&parent)
                                .map_or(0, |w| w.children().len());
                            new_selection = Some(parent.child(count.saturating_sub(1)));
                            new_panel = Some((next, format!("Add {name}")));
                        }
                        ui.close();
                    }
                }
            });
            if !selected.is_root() && ui.button("🗑 Remove").clicked() {
                let mut next = panel.clone();
                if next.root.remove_path(&selected).is_some() {
                    new_selection = Some(WidgetPath::root());
                    new_panel = Some((next, "Remove widget".to_owned()));
                }
            }
            if dirty {
                ui.weak("● unsaved");
            }
            if ui.button("Close").clicked() {
                do_close = true;
            }
        });
        ui.separator();

        // Everything below scrolls, so the property panel is always reachable
        // even when the canvas tab is short.
        egui::ScrollArea::vertical().show(ui, |ui| {
            // --- Widget tree ------------------------------------------------
            egui::CollapsingHeader::new("Widget tree")
                .default_open(true)
                .show(ui, |ui| {
                    ui_widget_tree(
                        ui,
                        &panel.root,
                        WidgetPath::root(),
                        &selected,
                        &mut new_selection,
                    );
                });
            ui.separator();

            // --- Preview ----------------------------------------------------
            ui.label("Preview");
            draw_ui_preview(ui, &panel);
            ui.separator();

            // --- Properties of the selected widget --------------------------
            if let Some(widget) = panel.root.get_path(&selected) {
                let mut edited = widget.clone();
                ui.label(format!("Properties — {}", widget_label(widget, &selected)));
                if self.ui_widget_properties(ui, &mut edited, &selected) {
                    let mut next = panel.clone();
                    if let Some(slot) = next.root.get_path_mut(&selected) {
                        *slot = edited;
                        new_panel = Some((next, "Edit widget".to_owned()));
                    }
                }
            }
        });

        // --- Apply collected actions ---------------------------------------
        if let Some(sel) = new_selection {
            if let Some(doc) = &mut self.state.ui_doc {
                doc.selected = sel;
            }
        }
        if let Some((after, label)) = new_panel {
            let before = self.state.ui_doc.as_ref().unwrap().panel.clone();
            self.commands.push(
                SetUiPanelCmd {
                    before,
                    after,
                    label,
                },
                self.state,
            );
        }
        if do_save {
            self.save_ui_doc();
        }
        if do_close {
            self.state.ui_doc = None;
        }
    }

    /// Lists the project's `ui/` documents (when a project is open) as buttons
    /// that open them into the canvas.
    fn ui_canvas_open_list(&mut self, ui: &mut egui::Ui) {
        let Some(db) = &self.state.asset_db else {
            return;
        };
        let mut docs: Vec<(AssetUid, String)> = db
            .entries_of_kind(AssetKind::Ui)
            .map(|e| (e.uid, e.path.clone()))
            .collect();
        docs.sort_by(|a, b| a.1.cmp(&b.1));
        if docs.is_empty() {
            return;
        }
        ui.separator();
        ui.label("Open a UI document:");
        let mut to_open: Option<(AssetUid, PathBuf)> = None;
        for (uid, path) in &docs {
            if ui.button(path).clicked() {
                if let Some(abs) = db.absolute_path(*uid) {
                    to_open = Some((*uid, abs));
                }
            }
        }
        if let Some((uid, abs)) = to_open {
            match std::fs::read_to_string(&abs)
                .ok()
                .and_then(|t| ron::from_str::<UiPanel>(&t).ok())
            {
                Some(panel) => {
                    self.state.ui_doc = Some(crate::state::UiDoc {
                        panel,
                        asset: Some(uid),
                        selected: WidgetPath::root(),
                        dirty: false,
                    });
                }
                None => log::warn!("could not load UI document {}", abs.display()),
            }
        }
    }

    /// Property editor for the selected widget. Returns whether anything
    /// changed (the caller then records one undoable panel edit). Reads the
    /// asset database for the font picker.
    fn ui_widget_properties(
        &self,
        ui: &mut egui::Ui,
        widget: &mut Widget,
        salt: &WidgetPath,
    ) -> bool {
        let mut changed = false;
        let id_salt = ("oxide.uicanvas.props", salt.0.clone());

        ui.horizontal(|ui| {
            ui.label("id");
            let mut id = widget.id.as_str().to_owned();
            if ui.text_edit_singleline(&mut id).changed() {
                widget.id = id.into();
                changed = true;
            }
        });

        // Text content (empty clears it).
        ui.horizontal(|ui| {
            ui.label("text");
            let mut text = widget.text.clone().unwrap_or_default();
            if ui.text_edit_singleline(&mut text).changed() {
                widget.text = (!text.is_empty()).then_some(text);
                changed = true;
            }
        });

        // Kind-specific parameters — what makes each widget type distinct.
        changed |= kind_properties(ui, &mut widget.kind, salt);

        ui.separator();
        changed |= optional_color(ui, "background", &mut widget.visual.background);
        changed |= optional_color(ui, "foreground", &mut widget.visual.foreground);

        // Font size (optional).
        ui.horizontal(|ui| {
            let mut on = widget.visual.font_size.is_some();
            if ui.checkbox(&mut on, "font size").changed() {
                widget.visual.font_size = on.then_some(14.0);
                changed = true;
            }
            if let Some(size) = &mut widget.visual.font_size {
                if ui
                    .add(egui::DragValue::new(size).range(4.0..=200.0))
                    .changed()
                {
                    changed = true;
                }
            }
        });

        // Font asset picker — the AssetRef<Font> end-to-end target.
        changed |= self.font_asset_picker(ui, &id_salt, &mut widget.visual.font_asset);

        // Layout — how this widget is sized and placed within its parent.
        ui.separator();
        egui::CollapsingHeader::new("Layout")
            .id_salt(("oxide.uicanvas.layout", salt.0.clone()))
            .default_open(true)
            .show(ui, |ui| {
                changed |= sizing_editor(ui, "width", &mut widget.style.width, 0);
                changed |= sizing_editor(ui, "height", &mut widget.style.height, 1);
                changed |= align_combo(ui, "align x", &mut widget.style.align_horizontal, 0);
                changed |= align_combo(ui, "align y", &mut widget.style.align_vertical, 1);
                changed |= insets_editor(ui, "padding", &mut widget.style.padding, 0);
                changed |= insets_editor(ui, "margin", &mut widget.style.margin, 1);
                changed |= anchor_combo(ui, &mut widget.style.anchor, salt);
                ui.weak(
                    "Tip: stacks/grids place children automatically; to move a \
                     child freely, put it in an Anchor parent and set its anchor.",
                );
            });

        changed
    }

    /// A dropdown that sets a [`VisualStyle::font_asset`] from the project's
    /// font assets (or clears it to inherit). The picker filters the asset
    /// database to [`AssetKind::Font`].
    fn font_asset_picker(
        &self,
        ui: &mut egui::Ui,
        id_salt: &(&str, Vec<usize>),
        font_asset: &mut Option<AssetRef<oxide_engine::ui::Font>>,
    ) -> bool {
        let mut changed = false;
        ui.horizontal(|ui| {
            ui.label("font asset");
            let Some(db) = &self.state.asset_db else {
                ui.weak("(open a project)");
                return;
            };
            let mut fonts: Vec<(AssetUid, String)> = db
                .entries_of_kind(AssetKind::Font)
                .map(|e| {
                    let leaf = e.path.rsplit('/').next().unwrap_or(&e.path).to_owned();
                    (e.uid, leaf)
                })
                .collect();
            fonts.sort_by(|a, b| a.1.cmp(&b.1));
            let current_uid = font_asset.and_then(|r| r.uid());
            let selected_text = match current_uid {
                Some(uid) => db
                    .relative_path(uid)
                    .map(|p| p.rsplit('/').next().unwrap_or(p).to_owned())
                    .unwrap_or_else(|| "(missing)".to_owned()),
                None => "(inherit)".to_owned(),
            };
            egui::ComboBox::from_id_salt(id_salt)
                .selected_text(selected_text)
                .show_ui(ui, |ui| {
                    if ui
                        .selectable_label(current_uid.is_none(), "(inherit)")
                        .clicked()
                    {
                        *font_asset = None;
                        changed = true;
                    }
                    for (uid, leaf) in &fonts {
                        if ui
                            .selectable_label(current_uid == Some(*uid), leaf)
                            .clicked()
                        {
                            *font_asset = Some(AssetRef::new(*uid));
                            changed = true;
                        }
                    }
                });
        });
        changed
    }

    /// Saves the open UI document as a `ui/` asset (RON). A never-saved
    /// document is written to `ui/untitled.ron` and registered in the database.
    fn save_ui_doc(&mut self) {
        let Some(db) = &self.state.asset_db else {
            log::warn!("cannot save UI document without an open project");
            return;
        };
        let doc = self.state.ui_doc.as_ref().unwrap();
        let rel = match doc.asset.and_then(|uid| db.relative_path(uid)) {
            Some(rel) => rel.to_owned(),
            None => "ui/untitled.ron".to_owned(),
        };
        let abs = db.assets_dir().join(&rel);
        let Ok(text) = ron::ser::to_string_pretty(&doc.panel, ron::ser::PrettyConfig::default())
        else {
            log::warn!("failed to serialize UI document");
            return;
        };
        if let Some(parent) = abs.parent() {
            if let Err(err) = std::fs::create_dir_all(parent) {
                log::warn!("could not create {}: {err}", parent.display());
                return;
            }
        }
        if let Err(err) = std::fs::write(&abs, text) {
            log::warn!("could not write {}: {err}", abs.display());
            return;
        }
        // Register a newly-saved document and remember its uid.
        if let Some(db) = &mut self.state.asset_db {
            let uid = db.register(&rel);
            let _ = db.save();
            if let Some(doc) = &mut self.state.ui_doc {
                doc.asset = Some(uid);
                doc.dirty = false;
            }
        }
    }

    fn console(&mut self, ui: &mut egui::Ui) {
        let Some(buffer) = crate::console::log_buffer() else {
            ui.weak("Console: logging is not initialised.");
            return;
        };

        // Toolbar: line count + Clear.
        ui.horizontal(|ui| {
            let count = buffer.lock().map(|b| b.len()).unwrap_or(0);
            ui.weak(format!("{count} line(s)"));
            if ui.button("Clear").clicked() {
                if let Ok(mut b) = buffer.lock() {
                    b.clear();
                }
            }
            ui.weak("·  script print/errors appear here");
        });

        // Command prompt: runs a shell command in the project root, streaming
        // its output into this same panel.
        ui.horizontal(|ui| {
            ui.label("$");
            let input = egui::TextEdit::singleline(self.terminal_input)
                .hint_text("run a command…")
                .desired_width(f32::INFINITY)
                .font(egui::TextStyle::Monospace);
            let resp = ui.add(input);
            let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
            if submitted && !self.terminal_input.trim().is_empty() {
                let command = std::mem::take(self.terminal_input);
                let cwd = self
                    .state
                    .asset_db
                    .as_ref()
                    .map(|db| db.root().to_path_buf())
                    .unwrap_or_else(|| {
                        std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
                    });
                crate::terminal::run(&command, &cwd);
                resp.request_focus(); // keep typing without re-clicking
            }
        });
        ui.separator();

        // The log lines, newest pinned to the bottom, coloured by severity.
        egui::ScrollArea::vertical()
            .auto_shrink([false, false])
            .stick_to_bottom(true)
            .show(ui, |ui| {
                let Ok(b) = buffer.lock() else {
                    return;
                };
                if b.is_empty() {
                    ui.weak("(no output yet)");
                    return;
                }
                for line in b.iter() {
                    let color = level_color(ui, line.level);
                    // Compact one-line entry: [LEVEL target] message.
                    let head = format!("[{} {}]", line.level, line.target);
                    ui.horizontal_wrapped(|ui| {
                        ui.spacing_mut().item_spacing.x = 6.0;
                        ui.colored_label(color, head);
                        ui.label(&line.message);
                    });
                }
            });
    }

    /// The interactive PTY terminal panel: a tab strip of sessions plus the
    /// active session's screen grid; keystrokes route to the active terminal.
    fn terminal_panel(&mut self, ui: &mut egui::Ui) {
        // Auto-close sessions whose program has exited, then keep the active
        // index in range — so typing `exit` (or the agent finishing) drops the
        // tab instead of leaving a dead one behind.
        self.terminals.retain_mut(|t| !t.has_exited());
        if *self.active_terminal >= self.terminals.len() {
            *self.active_terminal = self.terminals.len().saturating_sub(1);
        }

        // Tab strip: one selectable label per session (with a close button) and
        // a "+" to open another shell.
        let mut to_close: Option<usize> = None;
        ui.horizontal(|ui| {
            for idx in 0..self.terminals.len() {
                let selected = idx == *self.active_terminal;
                let label = format!("{} {}", self.terminals[idx].title, idx + 1);
                if ui.selectable_label(selected, label).clicked() {
                    *self.active_terminal = idx;
                }
                if ui.small_button("x").on_hover_text("Close").clicked() {
                    to_close = Some(idx);
                }
                ui.separator();
            }
            if ui.button("+ Shell").clicked() {
                let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
                self.launch_terminal("shell", &shell, &[]);
            }
        });
        if let Some(idx) = to_close {
            if idx < self.terminals.len() {
                self.terminals.remove(idx); // Drop kills the child.
                if *self.active_terminal >= self.terminals.len() {
                    *self.active_terminal = self.terminals.len().saturating_sub(1);
                }
            }
        }
        ui.separator();

        if self.terminals.is_empty() {
            ui.weak(
                "No terminal. Click + Shell to start one — it runs in the project directory \
                 and supports interactive / full-screen programs (a shell, a REPL, vim, an \
                 AI-agent CLI like claude).",
            );
            return;
        }
        let term = &mut self.terminals[*self.active_terminal];

        // A live session: keep repainting so streamed output shows promptly.
        ui.ctx().request_repaint();

        // Size the grid to the panel using the monospace cell metrics.
        let font = egui::FontId::monospace(13.0);
        let (cell_w, cell_h) = ui
            .ctx()
            .fonts_mut(|f| (f.glyph_width(&font, 'M'), f.row_height(&font)));
        let avail = ui.available_size();
        let cols = ((avail.x / cell_w).floor() as u16).max(1);
        let rows = ((avail.y / cell_h).floor() as u16).max(1);
        term.resize(rows, cols);

        // Build the grid as a monospace LayoutJob and paint it into a focusable
        // region so the panel can capture keystrokes.
        let default_fg = ui.visuals().text_color();
        let bg = ui.visuals().extreme_bg_color;
        let job = term.with_screen(|screen| build_terminal_job(screen, &font, default_fg));
        let galley = ui.painter().layout_job(job);

        let (rect, response) = ui.allocate_exact_size(avail, egui::Sense::click());
        if response.clicked() {
            response.request_focus();
        }
        // Deliver Tab / arrows / Escape to the terminal instead of letting egui
        // use them to move focus — a terminal program needs all of them (Tab
        // completion, arrow navigation, vim's Esc).
        ui.memory_mut(|m| {
            m.set_focus_lock_filter(
                response.id,
                egui::EventFilter {
                    tab: true,
                    horizontal_arrows: true,
                    vertical_arrows: true,
                    escape: true,
                },
            )
        });
        ui.painter().rect_filled(rect, 0.0, bg);
        ui.painter().galley(rect.min, galley, default_fg);

        // Route input only while focused, so typing elsewhere isn't captured.
        if response.has_focus() {
            let events = ui.input(|i| i.events.clone());
            for event in events {
                match event {
                    egui::Event::Text(text) => term.send_input(text.as_bytes()),
                    egui::Event::Paste(text) => term.send_input(text.as_bytes()),
                    egui::Event::Key {
                        key,
                        pressed: true,
                        modifiers,
                        ..
                    } => {
                        if let Some(bytes) = crate::pty::encode_key(key, modifiers) {
                            term.send_input(&bytes);
                        }
                    }
                    _ => {}
                }
            }
        } else {
            ui.weak("(click to focus and type)");
        }
    }

    /// Spawns a terminal session running `program` (with `args`) in the project
    /// directory, appends it as a new tab, and makes it active.
    fn launch_terminal(&mut self, title: &str, program: &str, args: &[&str]) {
        let cwd = self
            .state
            .asset_db
            .as_ref()
            .map(|db| db.root().to_path_buf())
            .unwrap_or_else(|| {
                std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
            });
        match crate::pty::PtyTerminal::spawn(title, program, args, &cwd, 24, 80) {
            Ok(term) => {
                self.terminals.push(term);
                *self.active_terminal = self.terminals.len() - 1;
            }
            Err(err) => {
                log::error!(target: "terminal", "failed to launch {program}: {err}");
            }
        }
    }

    fn custom_panel(&mut self, ui: &mut egui::Ui, name: &str) {
        for panel in self.extensions.iter_panels_mut() {
            if panel.name == name {
                (panel.render)(ui);
                return;
            }
        }
        ui.weak(format!("Panel '{name}' is not registered."));
    }
}

// ---- helpers -----------------------------------------------------------

/// Returns whether the rendered control changed any axis this frame.
/// Projects a world-space point through `view_proj` to screen pixels
/// inside `tab_rect` (egui logical points). Returns `None` when the point
/// is behind the camera (negative-w) so callers can skip painting that
/// vertex / handle.
fn project(
    world: oxide_engine::math::Vec3,
    view_proj: &Mat4,
    tab_rect: egui::Rect,
) -> Option<egui::Pos2> {
    let clip: Vec4 = *view_proj * world.extend(1.0);
    if clip.w <= 0.0 {
        return None;
    }
    let x_ndc = clip.x / clip.w;
    let y_ndc = clip.y / clip.w;
    let x = tab_rect.min.x + (x_ndc * 0.5 + 0.5) * tab_rect.width();
    // Y flips: NDC up = screen down.
    let y = tab_rect.min.y + (1.0 - (y_ndc * 0.5 + 0.5)) * tab_rect.height();
    Some(egui::Pos2::new(x, y))
}

/// The end of the raycast-probe normal whisker: `point` plus `normal`
/// (re-normalized) scaled to `len`. Factored out so the geometry is unit-tested
/// and `paint_raycast_probe` only does the projection. A zero/degenerate normal
/// collapses to `point` (no whisker drawn).
fn probe_normal_tip(
    point: oxide_engine::math::Vec3,
    normal: oxide_engine::math::Vec3,
    len: f32,
) -> oxide_engine::math::Vec3 {
    point + normal.normalize_or_zero() * len
}

/// Two unit vectors orthogonal to `n` (and to each other), forming a
/// right-handed basis with `n`. Used to parameterize the rotate-axis
/// circle in 3D.
fn orthonormal_basis(
    n: oxide_engine::math::Vec3,
) -> (oxide_engine::math::Vec3, oxide_engine::math::Vec3) {
    use oxide_engine::math::Vec3;
    // Pick a reference axis that isn't (near-) collinear with `n`.
    let reference = if n.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
    let u = n.cross(reference).normalize();
    let v = n.cross(u);
    (u, v)
}

/// Local-space line segments that outline a physics
/// [`Collider`](oxide_physics::Collider)'s shape, as `(start, end)` pairs in
/// the collider's local frame (the caller applies the world pose). Used by the
/// viewport collider gizmos (Stage 9 piece 8b). Conventions match the
/// simulation: capsule/cylinder axes are local `+Y`; sphere/capsule/cylinder
/// use `radius`, box uses `half_extents`, capsule/cylinder add `half_height`.
fn collider_wire_segments(
    col: &oxide_physics::Collider,
) -> Vec<(oxide_engine::math::Vec3, oxide_engine::math::Vec3)> {
    use oxide_engine::math::Vec3;
    use oxide_physics::ColliderShape;
    use std::f32::consts::{PI, TAU};

    let mut segs: Vec<(Vec3, Vec3)> = Vec::new();
    match col.shape {
        ColliderShape::Box => {
            let h = col.half_extents;
            // 8 corners: indices 0..3 bottom (-Y), 4..7 top (+Y).
            let c = [
                Vec3::new(-h.x, -h.y, -h.z),
                Vec3::new(h.x, -h.y, -h.z),
                Vec3::new(h.x, -h.y, h.z),
                Vec3::new(-h.x, -h.y, h.z),
                Vec3::new(-h.x, h.y, -h.z),
                Vec3::new(h.x, h.y, -h.z),
                Vec3::new(h.x, h.y, h.z),
                Vec3::new(-h.x, h.y, h.z),
            ];
            const EDGES: [(usize, usize); 12] = [
                (0, 1),
                (1, 2),
                (2, 3),
                (3, 0), // bottom face
                (4, 5),
                (5, 6),
                (6, 7),
                (7, 4), // top face
                (0, 4),
                (1, 5),
                (2, 6),
                (3, 7), // verticals
            ];
            for (a, b) in EDGES {
                segs.push((c[a], c[b]));
            }
        }
        ColliderShape::Sphere => {
            let r = col.radius;
            // Three great circles, one per coordinate plane.
            push_arc(&mut segs, Vec3::ZERO, Vec3::X, Vec3::Y, r, (0.0, TAU), 24);
            push_arc(&mut segs, Vec3::ZERO, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
            push_arc(&mut segs, Vec3::ZERO, Vec3::Y, Vec3::Z, r, (0.0, TAU), 24);
        }
        ColliderShape::Capsule => {
            let (r, hh) = (col.radius, col.half_height);
            let (top, bot) = (Vec3::Y * hh, Vec3::Y * -hh);
            // Seam rings where the hemispheres meet the cylinder.
            push_arc(&mut segs, top, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
            push_arc(&mut segs, bot, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
            // Cylinder side lines on the four cardinal directions.
            for d in [Vec3::X, -Vec3::X, Vec3::Z, -Vec3::Z] {
                segs.push((bot + d * r, top + d * r));
            }
            // Hemisphere cap profiles (over the top, under the bottom).
            push_arc(&mut segs, top, Vec3::X, Vec3::Y, r, (0.0, PI), 12);
            push_arc(&mut segs, top, Vec3::Z, Vec3::Y, r, (0.0, PI), 12);
            push_arc(&mut segs, bot, Vec3::X, Vec3::Y, r, (PI, TAU), 12);
            push_arc(&mut segs, bot, Vec3::Z, Vec3::Y, r, (PI, TAU), 12);
        }
        ColliderShape::Cylinder => {
            let (r, hh) = (col.radius, col.half_height);
            let (top, bot) = (Vec3::Y * hh, Vec3::Y * -hh);
            push_arc(&mut segs, top, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
            push_arc(&mut segs, bot, Vec3::X, Vec3::Z, r, (0.0, TAU), 24);
            for d in [Vec3::X, -Vec3::X, Vec3::Z, -Vec3::Z] {
                segs.push((bot + d * r, top + d * r));
            }
        }
    }
    segs
}

/// Appends `segs` line segments tracing the arc of a circle centred at `c`,
/// radius `r`, in the plane spanned by unit vectors `u`/`v`, sweeping the angle
/// `range.0..range.1` (radians). A full `0..TAU` sweep traces a closed circle.
/// Helper for [`collider_wire_segments`].
fn push_arc(
    out: &mut Vec<(oxide_engine::math::Vec3, oxide_engine::math::Vec3)>,
    c: oxide_engine::math::Vec3,
    u: oxide_engine::math::Vec3,
    v: oxide_engine::math::Vec3,
    r: f32,
    range: (f32, f32),
    segs: usize,
) {
    let (a0, a1) = range;
    let segs = segs.max(1);
    let point = |t: f32| c + u * (r * t.cos()) + v * (r * t.sin());
    let mut prev = point(a0);
    for i in 1..=segs {
        let t = a0 + (a1 - a0) * (i as f32) / (segs as f32);
        let p = point(t);
        out.push((prev, p));
        prev = p;
    }
}

/// The screen color for an axis handle. Brighter when the handle is the
/// active drag target so the user sees what they're holding.
fn axis_color(axis: gizmo::Axis3, engaged: bool) -> egui::Color32 {
    use egui::Color32;
    use gizmo::Axis3;
    let (r, g, b): (u8, u8, u8) = match axis {
        Axis3::X => (210, 60, 60),
        Axis3::Y => (60, 200, 60),
        Axis3::Z => (60, 110, 230),
    };
    if engaged {
        // Lerp toward white for the engaged feedback.
        Color32::from_rgb(
            ((r as u16 + 255) / 2) as u8,
            ((g as u16 + 255) / 2) as u8,
            ((b as u16 + 255) / 2) as u8,
        )
    } else {
        Color32::from_rgb(r, g, b)
    }
}

/// A display label for layer `index`: its registered name, or `"Layer N"` for
/// an unnamed slot. Shared by the single-select `Layer` widget and the
/// multi-select `LayerMask` field widget.
fn layer_label(index: u32, reg: &LayerRegistry) -> String {
    reg.name(index)
        .map(String::from)
        .unwrap_or_else(|| format!("Layer {index}"))
}

/// A short summary of a [`LayerMask`] for a collapsed combo box: `"All"`,
/// `"None"`, or the comma-joined names of its set layers.
fn mask_summary(mask: LayerMask, reg: &LayerRegistry) -> String {
    if mask == LayerMask::ALL {
        return "All".to_string();
    }
    if mask.is_empty() {
        return "None".to_string();
    }
    mask.iter()
        .map(|i| layer_label(i, reg))
        .collect::<Vec<_>>()
        .join(", ")
}

/// Adds or removes group `group` from `entity`'s [`Tags`], creating the `Tags`
/// component on first add and dropping it again when the last group is removed
/// (so an entity in no groups carries no empty marker). Structural, so — like
/// component add/remove/disable — it bypasses the undo stack for now.
fn apply_group_membership(
    world: &mut oxide_engine::hecs::World,
    entity: Entity,
    group: &str,
    member: bool,
) {
    use oxide_engine::layer::Tags;
    if member {
        // Update in place if the component exists; otherwise create it. The
        // borrow guard must be released (the `if let` scope ends) before we can
        // `insert_one`, so the existence check returns a bool first.
        let updated = if let Ok(mut tags) = world.get::<&mut Tags>(entity) {
            tags.insert(group);
            true
        } else {
            false
        };
        if !updated {
            let mut tags = Tags::new();
            tags.insert(group);
            let _ = world.insert_one(entity, tags);
        }
    } else {
        let now_empty = if let Ok(mut tags) = world.get::<&mut Tags>(entity) {
            tags.remove(group);
            tags.is_empty()
        } else {
            false
        };
        if now_empty {
            let _ = world.remove_one::<Tags>(entity);
        }
    }
}

fn vec3_drag(ui: &mut egui::Ui, label: &str, v: &mut Vec3, speed: f64) -> bool {
    let mut changed = false;
    ui.horizontal(|ui| {
        ui.label(label);
        for (axis, value) in [("x", &mut v.x), ("y", &mut v.y), ("z", &mut v.z)] {
            changed |= ui
                .add(egui::DragValue::new(value).speed(speed).prefix(axis))
                .changed();
        }
    });
    changed
}

/// The node-baked component types — every entity inherently carries these,
/// they're single-instance, and the inspector renders them in a fixed
/// canonical order with no enable / remove / drag-reorder controls.
///
/// Maintainer's distinction: these are part of *being a node*, not a feature
/// you add or remove. Modular components (`MeshRenderer`, future colliders /
/// scripts / …) are everything else.
const ESSENTIAL_COMPONENTS: &[&str] = &["Node", "Transform", "Layer"];

fn is_essential_component(name: &str) -> bool {
    ESSENTIAL_COMPONENTS.contains(&name)
}

/// Moves `dragged` to be just before (`place_before = true`) or just after
/// (`place_before = false`) `target` in the component order. Returns the new
/// order. Extracted from `reflected_components` so the index-juggling can be
/// unit-tested directly.
fn reorder_component_list(
    mut order: Vec<&'static str>,
    dragged: &'static str,
    target: &'static str,
    place_before: bool,
) -> Vec<&'static str> {
    // Dropping a row onto itself is a no-op (the GUI excludes this anyway;
    // guarding here keeps the helper composable for tests + future callers).
    if dragged == target {
        return order;
    }
    if let Some(from) = order.iter().position(|n| *n == dragged) {
        order.remove(from);
    }
    let mut to = order
        .iter()
        .position(|n| *n == target)
        .unwrap_or(order.len());
    if !place_before {
        to += 1;
    }
    to = to.min(order.len());
    order.insert(to, dragged);
    order
}

/// Toggles a component's disabled flag on `entity`'s [`DisabledComponents`],
/// creating the component lazily and removing it again when no entries remain
/// (so the entity doesn't carry an empty marker).
fn apply_component_disable(
    world: &mut oxide_engine::hecs::World,
    entity: Entity,
    type_name: &str,
    disabled: bool,
) {
    let already = world.get::<&DisabledComponents>(entity).is_ok();
    if already {
        let mut d = world.get::<&mut DisabledComponents>(entity).unwrap();
        d.set_disabled(type_name, disabled);
        if d.is_empty() {
            drop(d);
            let _ = world.remove_one::<DisabledComponents>(entity);
        }
    } else if disabled {
        let mut d = DisabledComponents::new();
        d.set_disabled(type_name, true);
        let _ = world.insert_one(entity, d);
    }
}

/// Orders the components currently on an entity for the inspector:
///
/// 1. `Transform` always renders first (it's the canonical positional row, and
///    every entity has one).
/// 2. Then any components named in `saved` order, in their saved order —
///    skipping any that are no longer present.
/// 3. Then any present component that isn't in `saved` yet, appended in
///    whatever order the registry returned it (so components inserted outside
///    the inspector — e.g. by a script or `set_ron` — still show up).
///
/// Extracted so it's straightforwardly unit-testable; the inspector calls this
/// with the registry's `components_on` list (minus `Node`) and the entity's
/// saved order from [`EditorState::component_order`].
fn inspector_component_order(
    present: &[&'static str],
    saved: &[&'static str],
) -> Vec<&'static str> {
    let mut remaining: Vec<&'static str> = present.to_vec();
    let mut out: Vec<&'static str> = Vec::with_capacity(remaining.len());
    if let Some(i) = remaining.iter().position(|n| *n == "Transform") {
        out.push(remaining.remove(i));
    }
    for name in saved {
        if let Some(i) = remaining.iter().position(|n| *n == *name) {
            out.push(remaining.remove(i));
        }
    }
    out.extend(remaining);
    out
}

/// Where a hierarchy drag-and-drop will drop, relative to the row under the
/// pointer: above it (reorder before), below it (reorder after), onto it (nest
/// as a child), or past the last row (append to the root level).
#[derive(Clone, Copy)]
enum DropZone {
    Before,
    After,
    Child,
    RootEnd,
}

/// A flattened hierarchy row, snapshotted each frame so the tree can be drawn
/// without holding a borrow on the scene.
struct Row {
    entity: Entity,
    depth: usize,
    name: String,
    /// The node's own authored flag (what the checkbox shows/edits).
    enabled: bool,
    /// Whether the node is enabled *and* every ancestor is — drives the greyed
    /// styling so a disabled parent visibly dims its whole subtree.
    effective_enabled: bool,
}

fn snapshot(scene: &Scene) -> Vec<Row> {
    let mut rows = Vec::with_capacity(scene.len());
    for &root in scene.roots() {
        snapshot_node(scene, root, 0, true, &mut rows);
    }
    rows
}

fn snapshot_node(
    scene: &Scene,
    entity: Entity,
    depth: usize,
    parent_effective: bool,
    rows: &mut Vec<Row>,
) {
    let enabled = scene.is_enabled(entity).unwrap_or(true);
    let effective_enabled = parent_effective && enabled;
    rows.push(Row {
        entity,
        depth,
        name: scene.name(entity).unwrap_or_default(),
        enabled,
        effective_enabled,
    });
    for &child in scene.children(entity) {
        snapshot_node(scene, child, depth + 1, effective_enabled, rows);
    }
}

/// Renders the typed asset folders (one collapsing section per [`AssetKind`])
/// from the database, listing each asset by its name. Drop a file into the
/// matching folder on disk to import it; the watcher (or the Rescan button)
/// registers it.
/// The colour for a console log line of the given severity: errors red, warnings
/// amber, info the normal text colour, debug/trace dimmed.
fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 {
    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
}

fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) {
    for kind in AssetKind::TYPED {
        let mut entries: Vec<&AssetEntry> = db.entries_of_kind(kind).collect();
        entries.sort_by(|a, b| a.path.cmp(&b.path));
        let header = format!("{}/  ({})", kind.folder(), entries.len());
        egui::CollapsingHeader::new(header)
            .id_salt(("oxide.assets", kind.folder()))
            .show(ui, |ui| {
                if entries.is_empty() {
                    ui.weak("(empty — drop files here)");
                }
                for entry in entries {
                    // Show the leaf name; the full relative path on hover.
                    let leaf = entry.path.rsplit('/').next().unwrap_or(&entry.path);
                    ui.label(leaf).on_hover_text(&entry.path);
                }
            });
    }
}

/// A short label for a widget in the tree / property header: its id if set,
/// else its kind, with the root marked.
fn widget_label(widget: &Widget, path: &WidgetPath) -> String {
    let kind = match &widget.kind {
        WidgetKind::Leaf { .. } => "Leaf",
        WidgetKind::Stack(s) => match s.direction {
            oxide_engine::ui::StackDirection::Row => "Row",
            oxide_engine::ui::StackDirection::Column => "Column",
        },
        WidgetKind::Grid(_) => "Grid",
        WidgetKind::Anchor(_) => "Anchor",
    };
    let name = if widget.id.is_empty() {
        kind.to_owned()
    } else {
        format!("{} ({kind})", widget.id.as_str())
    };
    if path.is_root() {
        format!("{name} ⌂")
    } else {
        name
    }
}

/// Renders the widget tree as selectable, indented rows. Sets `selection` to a
/// clicked node's path.
fn ui_widget_tree(
    ui: &mut egui::Ui,
    widget: &Widget,
    path: WidgetPath,
    selected: &WidgetPath,
    selection: &mut Option<WidgetPath>,
) {
    let label = widget_label(widget, &path);
    if ui.selectable_label(*selected == path, label).clicked() {
        *selection = Some(path.clone());
    }
    ui.indent(("oxide.uitree", path.0.clone()), |ui| {
        for (i, child) in widget.children().iter().enumerate() {
            ui_widget_tree(ui, child, path.child(i), selected, selection);
        }
    });
}

/// Converts an engine [`Color`] to an egui color.
fn color32(c: Color) -> egui::Color32 {
    let to = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
    egui::Color32::from_rgba_unmultiplied(to(c.r), to(c.g), to(c.b), to(c.a))
}

/// Draws a rectangle outline as four line segments (avoids depending on a
/// specific `rect_stroke` signature across egui versions).
fn stroke_rect(painter: &egui::Painter, rect: egui::Rect, stroke: egui::Stroke) {
    let tl = rect.left_top();
    let tr = rect.right_top();
    let br = rect.right_bottom();
    let bl = rect.left_bottom();
    for [a, b] in [[tl, tr], [tr, br], [br, bl], [bl, tl]] {
        painter.line_segment([a, b], stroke);
    }
}

/// A checkbox + color button for an `Option<Color>` visual field. Returns
/// whether it changed.
fn optional_color(ui: &mut egui::Ui, label: &str, slot: &mut Option<Color>) -> bool {
    let mut changed = false;
    ui.horizontal(|ui| {
        let mut on = slot.is_some();
        if ui.checkbox(&mut on, label).changed() {
            *slot = on.then_some(Color::WHITE);
            changed = true;
        }
        if let Some(c) = slot {
            let mut rgba = [c.r, c.g, c.b, c.a];
            if ui.color_edit_button_rgba_unmultiplied(&mut rgba).changed() {
                *c = Color::rgba(rgba[0], rgba[1], rgba[2], rgba[3]);
                changed = true;
            }
        }
    });
    changed
}

/// A `Sizing` editor: a mode dropdown plus a value drag for `Fixed`/`Grow`.
/// Returns whether it changed.
fn sizing_editor(ui: &mut egui::Ui, label: &str, sizing: &mut UiSizing, salt: usize) -> bool {
    let mut changed = false;
    ui.horizontal(|ui| {
        ui.label(label);
        let mode = match sizing {
            UiSizing::Fixed(_) => "Fixed",
            UiSizing::Grow(_) => "Grow",
            UiSizing::FitContent => "FitContent",
        };
        egui::ComboBox::from_id_salt(("oxide.uicanvas.sizing", label, salt))
            .selected_text(mode)
            .show_ui(ui, |ui| {
                if ui.selectable_label(mode == "Fixed", "Fixed").clicked() && mode != "Fixed" {
                    *sizing = UiSizing::Fixed(64.0);
                    changed = true;
                }
                if ui.selectable_label(mode == "Grow", "Grow").clicked() && mode != "Grow" {
                    *sizing = UiSizing::Grow(1.0);
                    changed = true;
                }
                if ui
                    .selectable_label(mode == "FitContent", "FitContent")
                    .clicked()
                    && mode != "FitContent"
                {
                    *sizing = UiSizing::FitContent;
                    changed = true;
                }
            });
        match sizing {
            UiSizing::Fixed(v) => {
                if ui
                    .add(egui::DragValue::new(v).range(0.0..=4096.0))
                    .changed()
                {
                    changed = true;
                }
            }
            UiSizing::Grow(w) => {
                if ui
                    .add(egui::DragValue::new(w).speed(0.1).range(0.0..=100.0))
                    .changed()
                {
                    changed = true;
                }
            }
            UiSizing::FitContent => {}
        }
    });
    changed
}

/// Type-specific parameters for the selected widget's [`WidgetKind`] — this is
/// what distinguishes a Grid from a Stack from a Leaf in the inspector. Returns
/// whether anything changed.
fn kind_properties(ui: &mut egui::Ui, kind: &mut WidgetKind, salt: &WidgetPath) -> bool {
    let mut changed = false;
    match kind {
        WidgetKind::Leaf { intrinsic } => {
            ui.horizontal(|ui| {
                ui.label("intrinsic size");
                changed |= ui
                    .add(
                        egui::DragValue::new(&mut intrinsic.x)
                            .prefix("w ")
                            .range(0.0..=4096.0),
                    )
                    .changed();
                changed |= ui
                    .add(
                        egui::DragValue::new(&mut intrinsic.y)
                            .prefix("h ")
                            .range(0.0..=4096.0),
                    )
                    .changed();
            });
        }
        WidgetKind::Stack(s) => {
            ui.horizontal(|ui| {
                ui.label("direction");
                let text = match s.direction {
                    UiStackDirection::Row => "Row",
                    UiStackDirection::Column => "Column",
                };
                egui::ComboBox::from_id_salt(("oxide.uicanvas.dir", salt.0.clone()))
                    .selected_text(text)
                    .show_ui(ui, |ui| {
                        for (label, dir) in [
                            ("Row", UiStackDirection::Row),
                            ("Column", UiStackDirection::Column),
                        ] {
                            if ui.selectable_label(s.direction == dir, label).clicked()
                                && s.direction != dir
                            {
                                s.direction = dir;
                                changed = true;
                            }
                        }
                    });
            });
            ui.horizontal(|ui| {
                ui.label("gap");
                changed |= ui
                    .add(egui::DragValue::new(&mut s.gap).range(0.0..=512.0))
                    .changed();
            });
            changed |= align_combo(ui, "main align", &mut s.main_align, 2);
        }
        WidgetKind::Grid(g) => {
            ui.horizontal(|ui| {
                ui.label("cols");
                changed |= ui
                    .add(egui::DragValue::new(&mut g.cols).range(1..=64))
                    .changed();
                ui.label("rows");
                changed |= ui
                    .add(egui::DragValue::new(&mut g.rows).range(1..=64))
                    .changed();
            });
            ui.horizontal(|ui| {
                ui.label("gap");
                changed |= ui
                    .add(
                        egui::DragValue::new(&mut g.gap.x)
                            .prefix("x ")
                            .range(0.0..=512.0),
                    )
                    .changed();
                changed |= ui
                    .add(
                        egui::DragValue::new(&mut g.gap.y)
                            .prefix("y ")
                            .range(0.0..=512.0),
                    )
                    .changed();
            });
        }
        WidgetKind::Anchor(_) => {
            ui.weak("Anchor container: each child is placed by its own anchor.");
        }
    }
    changed
}

/// A three-way [`Align`](oxide_engine::ui::Align) dropdown (Start/Center/End).
fn align_combo(ui: &mut egui::Ui, label: &str, align: &mut UiAlign, salt: usize) -> bool {
    let mut changed = false;
    ui.horizontal(|ui| {
        ui.label(label);
        let text = match align {
            UiAlign::Start => "Start",
            UiAlign::Center => "Center",
            UiAlign::End => "End",
        };
        egui::ComboBox::from_id_salt(("oxide.uicanvas.align", label, salt))
            .selected_text(text)
            .show_ui(ui, |ui| {
                for (name, value) in [
                    ("Start", UiAlign::Start),
                    ("Center", UiAlign::Center),
                    ("End", UiAlign::End),
                ] {
                    if ui.selectable_label(*align == value, name).clicked() && *align != value {
                        *align = value;
                        changed = true;
                    }
                }
            });
    });
    changed
}

/// Editor for the four sides of an [`Insets`](oxide_engine::ui::Insets) value.
fn insets_editor(ui: &mut egui::Ui, label: &str, insets: &mut UiInsets, salt: usize) -> bool {
    let mut changed = false;
    ui.horizontal(|ui| {
        ui.label(label);
        for (prefix, value) in [
            ("l", &mut insets.left),
            ("r", &mut insets.right),
            ("t", &mut insets.top),
            ("b", &mut insets.bottom),
        ] {
            let _ = salt;
            changed |= ui
                .add(
                    egui::DragValue::new(value)
                        .prefix(prefix)
                        .range(0.0..=512.0),
                )
                .changed();
        }
    });
    changed
}

/// A dropdown of the standard [`Anchor`](oxide_engine::ui::Anchor) presets
/// (Fill / corners / edges / center). Shows "Custom" if the current value
/// matches no preset. Anchor placement only takes effect under an Anchor parent.
fn anchor_combo(ui: &mut egui::Ui, anchor: &mut UiAnchor, salt: &WidgetPath) -> bool {
    let center = UiAnchor {
        min: Vec2::splat(0.5),
        max: Vec2::splat(0.5),
        offset_min: Vec2::ZERO,
        offset_max: Vec2::ZERO,
    };
    let presets: [(&str, UiAnchor); 10] = [
        ("Fill", UiAnchor::FILL),
        ("Top-Left", UiAnchor::TOP_LEFT),
        ("Top", UiAnchor::TOP),
        ("Top-Right", UiAnchor::TOP_RIGHT),
        ("Left", UiAnchor::LEFT),
        ("Center", center),
        ("Right", UiAnchor::RIGHT),
        ("Bottom-Left", UiAnchor::BOTTOM_LEFT),
        ("Bottom", UiAnchor::BOTTOM),
        ("Bottom-Right", UiAnchor::BOTTOM_RIGHT),
    ];
    let current = presets
        .iter()
        .find(|(_, a)| a == anchor)
        .map(|(n, _)| *n)
        .unwrap_or("Custom");
    let mut changed = false;
    ui.horizontal(|ui| {
        ui.label("anchor");
        egui::ComboBox::from_id_salt(("oxide.uicanvas.anchor", salt.0.clone()))
            .selected_text(current)
            .show_ui(ui, |ui| {
                for (name, value) in presets {
                    if ui.selectable_label(current == name, name).clicked() {
                        *anchor = value;
                        changed = true;
                    }
                }
            });
    });
    changed
}

/// Paints a scaled-to-fit preview of the panel into the available space using
/// egui's painter. Backgrounds/borders are drawn faithfully; text uses egui's
/// font (engine-font-accurate preview via the real `UiOverlayPass` is a noted
/// follow-up).
fn draw_ui_preview(ui: &mut egui::Ui, panel: &UiPanel) {
    let avail_w = ui.available_width().max(16.0);
    let aspect = if panel.pixel_size.x > 0.0 {
        panel.pixel_size.y / panel.pixel_size.x
    } else {
        0.5625
    };
    let height = (avail_w * aspect).clamp(120.0, 360.0);
    let (resp, painter) = ui.allocate_painter(egui::vec2(avail_w, height), egui::Sense::hover());
    let area = resp.rect;
    if panel.pixel_size.x <= 0.0 || panel.pixel_size.y <= 0.0 {
        return;
    }
    let scale = (area.width() / panel.pixel_size.x).min(area.height() / panel.pixel_size.y);
    let draw = egui::vec2(panel.pixel_size.x * scale, panel.pixel_size.y * scale);
    let origin = egui::pos2(
        area.center().x - draw.x / 2.0,
        area.center().y - draw.y / 2.0,
    );
    // Panel bounds.
    stroke_rect(
        &painter,
        egui::Rect::from_min_size(origin, draw),
        egui::Stroke::new(1.0, egui::Color32::DARK_GRAY),
    );

    let theme = UiTheme::new();
    let viewport = Rect::from_min_size(Vec2::ZERO, panel.pixel_size);
    let tree = ui_layout(&panel.root, viewport, 1.0);
    draw_preview_node(&painter, origin, scale, &theme, &panel.root, &tree, 0);
}

/// Recursively paints one widget (background, border, text) and its children
/// into the preview, mirroring `oxide_engine::ui::paint`'s structure.
fn draw_preview_node(
    painter: &egui::Painter,
    origin: egui::Pos2,
    scale: f32,
    theme: &UiTheme,
    widget: &Widget,
    tree: &oxide_engine::ui::LayoutTree,
    index: usize,
) {
    let node = &tree.nodes()[index];
    let resolved = widget.resolve_visual(theme);
    let to_screen = |r: Rect| {
        egui::Rect::from_min_max(
            egui::pos2(origin.x + r.min.x * scale, origin.y + r.min.y * scale),
            egui::pos2(origin.x + r.max.x * scale, origin.y + r.max.y * scale),
        )
    };
    if let Some(bg) = resolved.background {
        if !node.rect.is_empty() {
            painter.rect_filled(to_screen(node.rect), 0.0, color32(bg));
        }
    }
    if let Some(border) = resolved.border {
        if border.width > 0.0 {
            stroke_rect(
                painter,
                to_screen(node.rect),
                egui::Stroke::new((border.width * scale).max(1.0), color32(border.color)),
            );
        }
    }
    if let Some(text) = &widget.text {
        let color = resolved
            .foreground
            .map(color32)
            .unwrap_or(egui::Color32::WHITE);
        let size = (resolved.font_size.unwrap_or(14.0) * scale).max(4.0);
        let rect = to_screen(node.content_rect);
        painter.text(
            rect.left_top(),
            egui::Align2::LEFT_TOP,
            text,
            egui::FontId::proportional(size),
            color,
        );
    }
    for (child, &child_index) in widget.children().iter().zip(node.children.iter()) {
        draw_preview_node(
            painter,
            origin,
            scale,
            theme,
            child,
            tree,
            child_index as usize,
        );
    }
}

fn show_project_tree(ui: &mut egui::Ui, label: &str, root: PathBuf) {
    ui.collapsing(label, |ui| {
        let Ok(entries) = std::fs::read_dir(&root) else {
            ui.weak("(unreadable)");
            return;
        };
        let mut names: Vec<String> = entries
            .filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().into_owned()))
            .collect();
        names.sort();
        if names.is_empty() {
            ui.weak("(empty)");
        }
        for name in names {
            ui.label(name);
        }
    });
}

/// A small starter scene so the dock has something to show on launch.
fn starter_scene() -> Scene {
    let mut scene = Scene::new();
    let world = scene.spawn("world", Transform::IDENTITY);
    let player = scene.spawn_child(
        world,
        "player",
        Transform::from_translation(Vec3::new(0.0, 1.0, 0.0)),
    );
    scene.spawn_child(
        player,
        "camera",
        Transform::from_translation(Vec3::new(0.0, 1.6, 0.0)),
    );

    let level = scene.spawn_child(world, "level", Transform::IDENTITY);

    let ground = scene.spawn_child(
        level,
        "ground",
        Transform::from_scale(Vec3::new(20.0, 1.0, 20.0)),
    );
    attach_mesh(
        &mut scene,
        ground,
        PrimitiveShape::Plane,
        Material::diffuse(Color::rgb(0.28, 0.30, 0.33)),
    );

    let crate_e = scene.spawn_child(
        level,
        "prop_crate",
        Transform::from_translation(Vec3::new(2.0, 0.5, 1.0)),
    );
    attach_mesh(
        &mut scene,
        crate_e,
        PrimitiveShape::Cube,
        Material::diffuse(Color::rgb(0.80, 0.40, 0.15)),
    );

    let ball = scene.spawn_child(
        level,
        "ball",
        Transform::from_trs(Vec3::new(-1.5, 0.6, 0.0), Quat::IDENTITY, Vec3::splat(0.6)),
    );
    attach_mesh(
        &mut scene,
        ball,
        PrimitiveShape::Sphere,
        Material::metal(Color::rgb(0.55, 0.65, 0.95), 0.3),
    );

    scene
}

fn attach_mesh(scene: &mut Scene, entity: Entity, shape: PrimitiveShape, material: Material) {
    let _ = scene
        .world_mut()
        .insert_one(entity, MeshRenderer::with_material(shape, material));
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::SetTransformCmd;

    #[test]
    fn shell_starts_with_open_project_none() {
        let shell = Shell::new();
        assert!(shell.state.project.is_none());
        assert!(!shell.commands.can_undo());
        // Default layout includes the five built-in panel kinds.
        let titles: Vec<&str> = shell.dock.iter_all_tabs().map(|(_, t)| t.title()).collect();
        assert!(titles.contains(&"Hierarchy"));
        assert!(titles.contains(&"Inspector"));
        assert!(titles.contains(&"Viewport"));
        assert!(titles.contains(&"Project"));
        assert!(titles.contains(&"Console"));
    }

    #[test]
    fn inspector_order_pins_transform_first_then_saved_order_then_appends_rest() {
        // Transform always first, even when not first in `present`.
        let present = ["MeshRenderer", "Timer", "Transform"];
        let saved = ["Timer", "MeshRenderer"];
        assert_eq!(
            inspector_component_order(&present, &saved),
            vec!["Transform", "Timer", "MeshRenderer"]
        );

        // A saved entry that's no longer present is silently skipped.
        let saved_stale = ["Timer", "MeshRenderer", "Ghost"];
        assert_eq!(
            inspector_component_order(&present, &saved_stale),
            vec!["Transform", "Timer", "MeshRenderer"]
        );

        // A present component not in `saved` (e.g. inserted by a script via
        // set_ron) is appended after the saved order.
        let present_extra = ["Transform", "Timer", "MeshRenderer", "RigidBody"];
        let saved2 = ["Timer", "MeshRenderer"];
        assert_eq!(
            inspector_component_order(&present_extra, &saved2),
            vec!["Transform", "Timer", "MeshRenderer", "RigidBody"]
        );

        // Empty saved + only Transform present.
        assert_eq!(
            inspector_component_order(&["Transform"], &[]),
            vec!["Transform"]
        );
    }

    #[test]
    fn duplicate_preserves_inspector_component_order() {
        // Source has Transform + MeshRenderer with a specific saved order;
        // Duplicate carries that order over so the copy shows components in
        // the same arrangement instead of reverting to registry-default.
        let mut shell = Shell::from_state(EditorState::new());
        let src = shell.state.scene.spawn("src", Transform::IDENTITY);
        shell
            .state
            .scene
            .world_mut()
            .insert_one(src, MeshRenderer::new(PrimitiveShape::Cube))
            .unwrap();
        shell
            .state
            .component_order
            .insert(src, vec!["MeshRenderer"]);

        shell.apply(PendingAction::Duplicate(src));
        let new = shell.state.selected.unwrap();
        assert_eq!(
            shell.state.component_order.get(&new).map(Vec::as_slice),
            Some(["MeshRenderer"].as_slice())
        );
    }

    #[test]
    fn essential_component_classifier_pins_node_transform_layers() {
        for &name in &["Node", "Transform", "Layer"] {
            assert!(is_essential_component(name), "{name} should be essential");
        }
        // Modular components — every real one needs to be addable / removable
        // / reorderable.
        for &name in &["MeshRenderer", "RigidBody", "Tags", "MyScript"] {
            assert!(
                !is_essential_component(name),
                "{name} must not be classified essential"
            );
        }
    }

    #[test]
    fn layer_label_uses_registry_name_or_indexed_fallback() {
        let mut reg = LayerRegistry::new();
        reg.set(2, "Player");
        assert_eq!(layer_label(0, &reg), "Default"); // seeded by LayerRegistry
        assert_eq!(layer_label(2, &reg), "Player");
        assert_eq!(layer_label(7, &reg), "Layer 7"); // unnamed → indexed
    }

    #[test]
    fn mask_summary_reports_all_none_and_named_layers() {
        let mut reg = LayerRegistry::new();
        reg.set(1, "UI");
        reg.set(2, "Player");
        assert_eq!(mask_summary(LayerMask::ALL, &reg), "All");
        assert_eq!(mask_summary(LayerMask::NONE, &reg), "None");
        let mask = LayerMask::NONE.with(1).with(2).with(5);
        assert_eq!(mask_summary(mask, &reg), "UI, Player, Layer 5");
    }

    #[test]
    fn group_membership_creates_toggles_and_drops_tags() {
        use oxide_engine::layer::Tags;
        let mut scene = Scene::new();
        let e = scene.spawn("e", Transform::IDENTITY);
        // Tags is lazily attached — absent until the first group is added.
        assert!(scene.world().get::<&Tags>(e).is_err());

        apply_group_membership(scene.world_mut(), e, "Enemies", true);
        apply_group_membership(scene.world_mut(), e, "Pickups", true);
        {
            let tags = scene.world().get::<&Tags>(e).unwrap();
            assert!(tags.contains("Enemies"));
            assert!(tags.contains("Pickups"));
        }
        // Removing one keeps the component; removing the last drops it so an
        // entity in no groups carries no empty marker.
        apply_group_membership(scene.world_mut(), e, "Enemies", false);
        assert!(scene.world().get::<&Tags>(e).unwrap().contains("Pickups"));
        apply_group_membership(scene.world_mut(), e, "Pickups", false);
        assert!(scene.world().get::<&Tags>(e).is_err());
    }

    #[test]
    fn editor_seeds_default_layers_and_several_addable_components() {
        let state = EditorState::new();
        // The common starter layer names exist (besides "Default").
        assert_eq!(state.layer_registry.name(1), Some("UI"));
        assert_eq!(state.layer_registry.name(2), Some("Player"));
        assert_eq!(state.layer_registry.name(3), Some("World"));
        // Several distinct modular components are addable, so more than one can
        // be attached to a single node and drag-reordered.
        let addable: Vec<_> = state.registry.addable_names().collect();
        assert!(addable.contains(&"MeshRenderer"));
        assert!(addable.contains(&"Camera"));
        assert!(addable.contains(&"DirectionalLight"));
    }

    #[test]
    fn editor_seeds_builtin_prefabs() {
        let state = EditorState::new();
        let names: Vec<_> = state.prefab_registry.names().collect();
        for expected in [
            "Empty",
            "Cube",
            "Sphere",
            "Plane",
            "Camera",
            "Directional Light",
        ] {
            assert!(names.contains(&expected), "{expected} prefab missing");
        }
        // Built-in prefabs reference only registered component types.
        assert!(state
            .prefab_registry
            .unknown_specs("Cube", &state.registry)
            .is_empty());
        assert!(state
            .prefab_registry
            .unknown_specs("Directional Light", &state.registry)
            .is_empty());
    }

    #[test]
    fn add_root_prefab_spawns_entity_with_components_and_selects_it() {
        let mut shell = Shell::from_state(EditorState::new());
        shell.apply(PendingAction::AddRootPrefab("Cube".to_string()));
        let e = shell.state.selected.expect("spawned entity is selected");
        // Carries the prefab's MeshRenderer and takes the prefab's node name.
        assert!(shell
            .state
            .registry
            .has(shell.state.scene.world(), e, "MeshRenderer")
            .unwrap());
        assert_eq!(shell.state.scene.name(e).as_deref(), Some("Cube"));
    }

    #[test]
    fn add_child_prefab_parents_under_target() {
        let mut shell = Shell::from_state(EditorState::new());
        shell.apply(PendingAction::AddRootPrefab("Empty".to_string()));
        let parent = shell.state.selected.unwrap();
        shell.apply(PendingAction::AddChildPrefab(parent, "Camera".to_string()));
        let child = shell.state.selected.unwrap();
        assert_eq!(shell.state.scene.parent(child), Some(parent));
        assert!(shell
            .state
            .registry
            .has(shell.state.scene.world(), child, "Camera")
            .unwrap());
    }

    #[test]
    fn reorder_component_list_handles_all_directions() {
        // Move B before A (forward → backward).
        assert_eq!(
            reorder_component_list(vec!["A", "B", "C"], "B", "A", true),
            vec!["B", "A", "C"]
        );
        // Move A after C (backward → forward, three-way reshuffle).
        assert_eq!(
            reorder_component_list(vec!["A", "B", "C"], "A", "C", false),
            vec!["B", "C", "A"]
        );
        // Move B after A is a no-op (it's already there).
        assert_eq!(
            reorder_component_list(vec!["A", "B", "C"], "B", "A", false),
            vec!["A", "B", "C"]
        );
        // Move B before B (dropping on itself, sanity): same list returns.
        assert_eq!(
            reorder_component_list(vec!["A", "B", "C"], "B", "B", true),
            vec!["A", "B", "C"]
        );
    }

    #[test]
    fn apply_component_disable_creates_and_clears_marker_component() {
        let mut shell = Shell::from_state(EditorState::new());
        let e = shell.state.scene.spawn("e", Transform::IDENTITY);
        let world = shell.state.scene.world_mut();

        // Disabling adds the marker component with one entry.
        apply_component_disable(world, e, "MeshRenderer", true);
        let d = world.get::<&DisabledComponents>(e).unwrap();
        assert!(d.is_disabled("MeshRenderer"));
        drop(d);

        // Re-enabling removes the only entry → the marker is dropped entirely
        // so the entity isn't carrying empty metadata.
        apply_component_disable(world, e, "MeshRenderer", false);
        assert!(world.get::<&DisabledComponents>(e).is_err());
    }

    #[test]
    fn duplicate_copies_per_component_disable_set() {
        let mut shell = Shell::from_state(EditorState::new());
        let src = shell.state.scene.spawn("src", Transform::IDENTITY);
        shell
            .state
            .scene
            .world_mut()
            .insert_one(src, MeshRenderer::new(PrimitiveShape::Cube))
            .unwrap();
        apply_component_disable(shell.state.scene.world_mut(), src, "MeshRenderer", true);

        shell.apply(PendingAction::Duplicate(src));
        let new = shell.state.selected.unwrap();
        assert!(shell.state.scene.is_component_disabled(new, "MeshRenderer"));
    }

    #[test]
    fn delete_clears_inspector_component_order() {
        let mut shell = Shell::from_state(EditorState::new());
        let e = shell.state.scene.spawn("e", Transform::IDENTITY);
        shell.state.component_order.insert(e, vec!["MeshRenderer"]);

        shell.apply(PendingAction::Delete(e));
        assert!(!shell.state.component_order.contains_key(&e));
    }

    #[test]
    fn duplicate_copies_registered_components_like_mesh_renderer() {
        // Regression: until MeshRenderer was registered in the editor's
        // registry, Duplicate produced a "node copy" with no mesh.
        let mut shell = Shell::from_state(EditorState::new());
        let cube = shell.state.scene.spawn("cube", Transform::IDENTITY);
        shell
            .state
            .scene
            .world_mut()
            .insert_one(cube, MeshRenderer::new(PrimitiveShape::Sphere))
            .unwrap();

        shell.apply(PendingAction::Duplicate(cube));

        let new = shell.state.selected.expect("duplicate selects the copy");
        assert_eq!(shell.state.scene.name(new).as_deref(), Some("cube copy"));
        let mr = shell
            .state
            .scene
            .get::<MeshRenderer>(new)
            .expect("MeshRenderer copied");
        assert_eq!(mr.shape, PrimitiveShape::Sphere);
    }

    #[test]
    fn duplicate_clones_an_entity_as_a_sibling_with_components() {
        let mut shell = Shell::from_state(EditorState::new());
        let parent = shell.state.scene.spawn("parent", Transform::IDENTITY);
        let child = shell.state.scene.spawn_child(
            parent,
            "child",
            Transform::from_translation(Vec3::new(1.0, 2.0, 3.0)),
        );
        let before = shell.state.scene.len();

        shell.apply(PendingAction::Duplicate(child));

        // One new entity, selected, named "<name> copy", same parent + transform.
        assert_eq!(shell.state.scene.len(), before + 1);
        let new = shell.state.selected.expect("duplicate selects the copy");
        assert_ne!(new, child);
        assert_eq!(shell.state.scene.name(new).as_deref(), Some("child copy"));
        assert_eq!(shell.state.scene.parent(new), Some(parent));
        assert_eq!(
            shell.state.scene.local_transform(new).unwrap().translation,
            Vec3::new(1.0, 2.0, 3.0)
        );
    }

    #[test]
    fn ctrl_s_without_project_is_a_no_op_but_handled() {
        let mut shell = Shell::new();
        // Handled = true; the shortcut belongs to the shell even when there's
        // nothing to save (avoids the OS bell + lets us show a status hint).
        assert!(shell.try_consume_shortcut(true, false, Some('s')));
        assert!(shell.state.project.is_none());
    }

    #[test]
    fn ctrl_comma_toggles_preferences() {
        let mut shell = Shell::new();
        assert!(!shell.show_preferences);
        assert!(shell.try_consume_shortcut(true, false, Some(',')));
        assert!(shell.show_preferences);
        assert!(shell.try_consume_shortcut(true, false, Some(',')));
        assert!(!shell.show_preferences);
    }

    #[test]
    fn take_quit_request_consumes_the_flag_once() {
        // The host runner polls this each frame; once observed the flag
        // resets, so an accidental double-poll wouldn't exit twice.
        let mut shell = Shell::new();
        assert!(!shell.take_quit_request());
        shell.quit_requested = true;
        assert!(shell.take_quit_request());
        assert!(!shell.take_quit_request());
    }

    #[test]
    fn ctrl_z_y_drive_undo_redo() {
        let mut shell = Shell::new();
        let e = shell.state.scene.spawn("x", Transform::IDENTITY);
        let cmd = SetTransformCmd::new(
            &shell.state,
            e,
            Transform::from_translation(Vec3::splat(1.0)),
        )
        .unwrap();
        shell.commands.push(cmd, &mut shell.state);
        assert!(shell.commands.can_undo());

        assert!(shell.try_consume_shortcut(true, false, Some('z')));
        assert!(!shell.commands.can_undo());
        assert!(shell.commands.can_redo());

        assert!(shell.try_consume_shortcut(true, false, Some('y')));
        assert!(shell.commands.can_undo());
    }

    #[test]
    fn open_project_records_recent_and_clears_undo() {
        // Round-trip through a tempdir: create_project then close_project
        // exercises the watcher attach/detach path without GUI.
        let mut root = std::env::temp_dir();
        root.push(format!("oxide_shell_test_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);

        let mut shell = Shell::new();
        let e = shell.state.scene.spawn("x", Transform::IDENTITY);
        let cmd =
            SetTransformCmd::new(&shell.state, e, Transform::from_translation(Vec3::ONE)).unwrap();
        shell.commands.push(cmd, &mut shell.state);
        assert!(shell.commands.can_undo());

        shell.create_project(&root, "shell-test").expect("create");
        assert!(shell.state.project.is_some());
        assert_eq!(shell.state.recent.entries().len(), 1);
        // Project open clears history — a half-done edit shouldn't be undoable
        // across project boundaries.
        assert!(!shell.commands.can_undo());

        shell.close_project();
        assert!(shell.state.project.is_none());

        std::fs::remove_dir_all(&root).ok();
    }

    // --- Piece 5: input-bindings capture state machine --------------------

    use oxide_engine::input::ActionOverrides;
    use oxide_engine::winit::keyboard::KeyCode as KC;

    #[test]
    fn shell_registers_default_bindings_and_settings_section() {
        let shell = Shell::new();
        assert!(shell
            .state
            .actions
            .has(crate::bindings::action::TOGGLE_FLYTHROUGH));
        assert!(shell
            .state
            .actions
            .has_axis(crate::bindings::action::MOVE_RIGHT));
        assert!(
            shell
                .state
                .settings
                .is_registered(crate::bindings::SETTINGS_SECTION),
            "input.bindings settings section must be auto-registered by EditorState"
        );
    }

    #[test]
    fn capture_replaces_button_binding_and_marks_dirty() {
        let mut shell = Shell::new();
        // Sanity: F is the default toggle binding.
        assert_eq!(
            shell
                .state
                .actions
                .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
            &[Binding::Key(KC::KeyF)]
        );
        assert!(!shell.bindings_dirty);

        shell.begin_capture(CaptureTarget::Button {
            action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(),
            slot: BindingSlot::Replace(0),
        });
        assert!(shell.capture_active());

        // First frame after begin: no key pressed → still waiting.
        let mut input = InputState::new();
        assert!(!shell.try_complete_capture(&input));
        assert!(shell.capture_active());

        // Next frame: user presses Tab.
        input.press_key(KC::Tab);
        assert!(shell.try_complete_capture(&input));
        assert!(!shell.capture_active());
        assert_eq!(
            shell
                .state
                .actions
                .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
            &[Binding::Key(KC::Tab)]
        );
        assert!(shell.bindings_dirty, "capture must flip the dirty flag");
    }

    #[test]
    fn escape_cancels_capture_without_binding() {
        let mut shell = Shell::new();
        shell.begin_capture(CaptureTarget::Button {
            action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(),
            slot: BindingSlot::Replace(0),
        });

        let mut input = InputState::new();
        input.press_key(KC::Escape);
        assert!(shell.try_complete_capture(&input));
        assert!(!shell.capture_active());
        // Original binding intact.
        assert_eq!(
            shell
                .state
                .actions
                .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
            &[Binding::Key(KC::KeyF)]
        );
        assert!(!shell.bindings_dirty);
    }

    #[test]
    fn append_capture_adds_binding_to_slot() {
        let mut shell = Shell::new();
        shell.begin_capture(CaptureTarget::Button {
            action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(),
            slot: BindingSlot::Append,
        });

        let mut input = InputState::new();
        input.press_key(KC::Tab);
        shell.try_complete_capture(&input);
        assert_eq!(
            shell
                .state
                .actions
                .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
            &[Binding::Key(KC::KeyF), Binding::Key(KC::Tab)]
        );
    }

    #[test]
    fn axis_capture_routes_to_the_correct_direction() {
        let mut shell = Shell::new();
        shell.begin_capture(CaptureTarget::Axis {
            action: crate::bindings::action::MOVE_RIGHT.to_string(),
            side: AxisSide::Positive,
            slot: BindingSlot::Append,
        });

        let mut input = InputState::new();
        input.press_key(KC::ArrowRight);
        shell.try_complete_capture(&input);

        let axis = shell
            .state
            .actions
            .axis_bindings(crate::bindings::action::MOVE_RIGHT)
            .unwrap();
        assert!(axis.positive.contains(&Binding::Key(KC::ArrowRight)));
        // Negative direction untouched.
        assert_eq!(axis.negative, vec![Binding::Key(KC::KeyA)]);
    }

    #[test]
    fn restore_all_defaults_undoes_remap_and_marks_dirty() {
        let mut shell = Shell::new();
        shell.state.actions.set_bindings(
            crate::bindings::action::TOGGLE_FLYTHROUGH,
            vec![Binding::Key(KC::Tab)],
        );

        shell.restore_default_bindings();
        assert_eq!(
            shell
                .state
                .actions
                .bindings(crate::bindings::action::TOGGLE_FLYTHROUGH),
            &[Binding::Key(KC::KeyF)]
        );
        assert!(shell.take_bindings_dirty());
        assert!(!shell.take_bindings_dirty(), "dirty flag is consume-once");
    }

    #[test]
    fn capture_keeps_overrides_settings_section_in_sync() {
        let mut shell = Shell::new();
        shell.begin_capture(CaptureTarget::Button {
            action: crate::bindings::action::TOGGLE_FLYTHROUGH.to_string(),
            slot: BindingSlot::Replace(0),
        });
        let mut input = InputState::new();
        input.press_key(KC::Tab);
        shell.try_complete_capture(&input);

        // The settings section now reflects the remap, so a Settings::export
        // immediately after a capture captures the user's choice.
        let overrides = shell
            .state
            .settings
            .get::<ActionOverrides>(crate::bindings::SETTINGS_SECTION)
            .expect("overrides section is registered");
        assert_eq!(
            overrides.get(crate::bindings::action::TOGGLE_FLYTHROUGH),
            &[Binding::Key(KC::Tab)]
        );
    }

    #[test]
    fn play_controls_drive_play_state_and_clear_undo() {
        let mut shell = Shell::from_state(EditorState::new());
        let e = shell.state.scene.spawn("thing", Transform::IDENTITY);
        // Put something on the undo stack so we can prove play clears it.
        let cmd = RenameCmd::new(&shell.state, e, "renamed".to_string());
        shell.push_command(cmd);
        assert!(shell.commands.can_undo());

        shell.play();
        assert_eq!(shell.state.play, PlayState::Playing);
        assert!(shell.state.play_snapshot.is_some());
        // Entering play wipes the undo history (play edits never leak to edit).
        assert!(!shell.commands.can_undo());

        // Step is ignored while playing.
        shell.request_step();
        assert!(!shell.take_step_request());

        // Pause, then Step queues exactly one request.
        shell.toggle_pause();
        assert_eq!(shell.state.play, PlayState::Paused);
        shell.request_step();
        assert!(shell.take_step_request());
        assert!(!shell.take_step_request(), "step request is one-shot");

        // The Play/Resume button resumes from pause (regression: it used to call
        // play(), which no-ops while in play, leaving it stuck on Paused).
        shell.play_or_resume();
        assert_eq!(shell.state.play, PlayState::Playing);
        // While playing it's a no-op (the button is disabled in the UI too).
        shell.play_or_resume();
        assert_eq!(shell.state.play, PlayState::Playing);
    }

    #[test]
    fn stop_restores_the_scene_and_returns_to_editing() {
        let mut shell = Shell::from_state(EditorState::new());
        let e = shell.state.scene.spawn("thing", Transform::IDENTITY);
        let before = shell.state.scene.to_ron().unwrap();

        shell.play();
        // Mutate as a tick would, then Stop.
        shell
            .state
            .scene
            .set_local_transform(e, Transform::from_translation(Vec3::new(3.0, 0.0, 0.0)));
        shell.stop();

        assert_eq!(shell.state.play, PlayState::Editing);
        assert!(shell.state.play_snapshot.is_none());
        assert_eq!(shell.state.scene.to_ron().unwrap(), before);
    }

    #[test]
    fn ctrl_p_plays_then_toggles_pause() {
        let mut shell = Shell::from_state(EditorState::new());
        shell.state.scene.spawn("thing", Transform::IDENTITY);
        // Ctrl+P from editing → Play.
        assert!(shell.try_consume_shortcut(true, false, Some('p')));
        assert_eq!(shell.state.play, PlayState::Playing);
        // Ctrl+P while running → Pause.
        assert!(shell.try_consume_shortcut(true, false, Some('p')));
        assert_eq!(shell.state.play, PlayState::Paused);
        // Ctrl+. steps while paused.
        assert!(shell.try_consume_shortcut(true, false, Some('.')));
        assert!(shell.take_step_request());
    }

    #[test]
    fn box_collider_wireframe_has_twelve_edges() {
        let col = oxide_physics::Collider::cuboid(oxide_engine::math::Vec3::new(1.0, 2.0, 3.0));
        let segs = collider_wire_segments(&col);
        assert_eq!(segs.len(), 12, "a box outline is its 12 edges");
        // Every vertex sits on the half-extent box corner (|x|=1,|y|=2,|z|=3).
        for (a, b) in segs {
            for p in [a, b] {
                assert!((p.x.abs() - 1.0).abs() < 1e-5);
                assert!((p.y.abs() - 2.0).abs() < 1e-5);
                assert!((p.z.abs() - 3.0).abs() < 1e-5);
            }
        }
    }

    #[test]
    fn probe_normal_tip_extends_along_the_unit_normal() {
        use oxide_engine::math::Vec3;
        let point = Vec3::new(1.0, 2.0, 3.0);
        // A non-unit normal is renormalized, then scaled to `len`.
        let tip = probe_normal_tip(point, Vec3::new(0.0, 5.0, 0.0), 2.0);
        assert!((tip - Vec3::new(1.0, 4.0, 3.0)).length() < 1e-5);
        // A degenerate normal collapses to the point (no whisker).
        let tip0 = probe_normal_tip(point, Vec3::ZERO, 2.0);
        assert!((tip0 - point).length() < 1e-6);
    }

    #[test]
    fn sphere_collider_wireframe_stays_on_the_radius() {
        let r = 2.5;
        let col = oxide_physics::Collider::ball(r);
        let segs = collider_wire_segments(&col);
        // Three 24-segment great circles.
        assert_eq!(segs.len(), 24 * 3);
        for (a, _) in &segs {
            assert!(
                (a.length() - r).abs() < 1e-4,
                "every point is on the sphere"
            );
        }
    }

    #[test]
    fn capsule_wireframe_spans_the_full_height() {
        // Half-height 1.0, radius 0.5 → the cap poles reach +/-1.5 in Y.
        let col = oxide_physics::Collider::capsule(0.5, 1.0);
        let segs = collider_wire_segments(&col);
        let max_y = segs
            .iter()
            .flat_map(|(a, b)| [a.y, b.y])
            .fold(f32::MIN, f32::max);
        let min_y = segs
            .iter()
            .flat_map(|(a, b)| [a.y, b.y])
            .fold(f32::MAX, f32::min);
        assert!((max_y - 1.5).abs() < 1e-4, "top hemisphere pole at +1.5");
        assert!((min_y + 1.5).abs() < 1e-4, "bottom hemisphere pole at -1.5");
    }

    #[test]
    fn push_arc_closes_a_full_circle() {
        use oxide_engine::math::Vec3;
        let mut segs = Vec::new();
        push_arc(
            &mut segs,
            Vec3::ZERO,
            Vec3::X,
            Vec3::Z,
            1.0,
            (0.0, std::f32::consts::TAU),
            8,
        );
        assert_eq!(segs.len(), 8);
        // The chain is closed: the last segment's end equals the first's start.
        let first_start = segs.first().unwrap().0;
        let last_end = segs.last().unwrap().1;
        assert!((first_start - last_end).length() < 1e-5);
    }
}
