//! Oxide Editor — entry point.
//!
//! The in-engine editor is built as a first-class part of the Oxide project.
//! It grows alongside the engine, gaining new panels and tools at each stage.
//!
//! Stage 6 wires the framework pieces (command stack, project system, settings
//! framework, extension API, file watcher) into a docking
//! [`Shell`](oxide_editor::shell::Shell). The shell hosts the hierarchy,
//! inspector, viewport, project browser, and console as resizable dockable
//! panels under a top menu bar + bottom status bar, with a Preferences window
//! driven by `Settings`. This binary is glue: window/event loop, the 3D
//! viewport renderer + camera, and the egui paint pump.

#![deny(warnings)]

mod egui_layer;
mod viewport;

use egui_layer::EguiLayer;
use oxide_editor::bindings::action;
use oxide_editor::commands::SetTransformCmd;
use oxide_editor::gizmo::{self, GizmoDrag, GizmoMode};
use oxide_editor::play::{self, Tick};
use oxide_editor::{preferences, shell::Shell};
use oxide_engine::app::{App, DefaultModules};
use oxide_engine::prelude::*;
use oxide_engine::window::event::{
    ElementState, KeyCode, ModifiersState, MouseButton, MouseScrollDelta, PhysicalKey, WindowEvent,
};
use oxide_engine::window::RenderCtx;
use viewport::{CameraMode, Viewport};

/// World-space length of the gizmo arrows / handles, scaled per-frame by
/// camera distance so the gizmo stays roughly the same pixel size at any
/// zoom level. The pure-logic gizmo math is agnostic to this scale — it
/// just takes whatever value the host passes.
const GIZMO_SCREEN_HEIGHT_FRACTION: f32 = 0.13;

/// Pixel-distance threshold for a gizmo handle to count as "hit" by a
/// click. Converted to world units per-frame using the camera distance so
/// the same screen tolerance applies at any zoom.
const GIZMO_HIT_PIXEL_TOLERANCE: f32 = 10.0;

/// Background color of the 3D viewport (dark neutral gray).
const VIEWPORT_CLEAR: Color = Color::rgb(0.08, 0.08, 0.10);

struct EditorApp {
    shell: Shell,
    egui_layer: Option<EguiLayer>,
    viewport: Option<Viewport>,
    /// The play-mode runtime (Stage 8.7). `Some` exactly while the editor is
    /// playing or paused: built when Play starts (engine `App` + default
    /// modules), ticked each frame, and dropped when Stop returns to editing.
    /// The editor's scene is swapped into it for each tick and back out again,
    /// so `shell.state.scene` stays the single source of truth between frames.
    play_app: Option<App>,
    modifiers: ModifiersState,
    /// Last cursor position (physical px), for computing drag deltas.
    last_cursor: Option<(f32, f32)>,
    /// Left mouse held over the viewport — orbit (or pick on release).
    orbiting: bool,
    /// Right/middle mouse held over the viewport — pan (orbit mode) or
    /// look around (flythrough mode); the camera-mode dispatch happens in
    /// the cursor-moved handler.
    panning: bool,
    /// Accumulated cursor travel since the left press, to tell a click (select)
    /// from a drag (orbit).
    left_drag_dist: f32,
}

impl EditorApp {
    fn new() -> Self {
        let mut shell = Shell::new();
        // Defaults are registered by EditorState::new; layer any saved user
        // remap from `~/.config/oxide/editor.ron` on top before the first
        // input poll runs.
        if let Some(saved) = preferences::load() {
            shell.state.settings.import(&saved);
            shell.state.apply_action_overrides_from_settings();
        }
        Self {
            shell,
            egui_layer: None,
            viewport: None,
            play_app: None,
            modifiers: ModifiersState::empty(),
            last_cursor: None,
            orbiting: false,
            panning: false,
            left_drag_dist: 0.0,
        }
    }

    /// Constructs the world-space cursor ray using the active viewport
    /// camera + the Viewport tab's sub-rect. Returns `None` if the viewport
    /// hasn't been initialized yet or the last cursor is unknown.
    fn cursor_ray(&self, size: (u32, u32)) -> Option<oxide_engine::math::Ray> {
        let cursor = self.last_cursor?;
        let vp = self.viewport.as_ref()?;
        Some(vp.ray_from_cursor(cursor, size, self.shell.viewport_rect()))
    }

    /// World-space gizmo size that the maths uses for both rendering and
    /// hit testing. Scaled by the camera's distance to the selection so the
    /// gizmo keeps a stable pixel size at any zoom level.
    fn gizmo_world_size(&self, target: oxide_engine::math::Vec3) -> f32 {
        let Some(vp) = self.viewport.as_ref() else {
            return 1.0;
        };
        let eye = match vp.mode {
            CameraMode::Orbit => vp.orbit.view_transform().translation,
            CameraMode::Flythrough => vp.flythrough.position,
        };
        let d = (target - eye).length().max(0.1);
        d * GIZMO_SCREEN_HEIGHT_FRACTION
    }

    /// Tries to start a gizmo drag at the cursor. Returns `true` if a
    /// handle was hit (so the caller can skip orbit/look for this click).
    fn try_begin_gizmo_drag(&mut self, size: (u32, u32)) -> bool {
        let Some(selected) = self.shell.state.selected else {
            return false;
        };
        let Some(transform) = self.shell.state.scene.world_transform(selected) else {
            return false;
        };
        let Some(ray) = self.cursor_ray(size) else {
            return false;
        };
        let world_size = self.gizmo_world_size(transform.translation);
        // Hit tolerance is a fixed pixel size; convert to world units the
        // same way the gizmo size is scaled (the math is approximate but
        // good enough for the few-pixel target zone).
        let tolerance = world_size * (GIZMO_HIT_PIXEL_TOLERANCE / 100.0);
        let mode = self.shell.state.gizmo.mode;
        let Some(handle) = gizmo::hit_test(&ray, &transform, mode, world_size, tolerance) else {
            return false;
        };
        // Compute the drag's start anchor — the point on the engaged
        // handle the click corresponds to. Mirrors what `apply_drag`
        // expects on subsequent frames.
        let start_anchor = match handle {
            gizmo::GizmoHandle::TranslateAxis(axis) | gizmo::GizmoHandle::ScaleAxis(axis) => {
                gizmo::closest_point_on_line(transform.translation, axis.unit(), &ray)
            }
            gizmo::GizmoHandle::TranslatePlane(plane) => {
                let p = oxide_engine::math::Plane::from_point_normal(
                    transform.translation,
                    plane.normal(),
                );
                p.ray_intersection(&ray)
                    .map(|t| ray.at(t))
                    .unwrap_or(transform.translation)
            }
            gizmo::GizmoHandle::RotateAxis(axis) => {
                let p = oxide_engine::math::Plane::from_point_normal(
                    transform.translation,
                    axis.unit(),
                );
                p.ray_intersection(&ray)
                    .map(|t| ray.at(t))
                    .unwrap_or(transform.translation)
            }
            gizmo::GizmoHandle::ScaleUniform => ray.closest_point(transform.translation),
        };
        // The uniform-scale handle uses `reference` as the world distance
        // corresponding to one factor of change — match it to the gizmo
        // size so dragging by ~one arm's length doubles the scale.
        let reference = if matches!(handle, gizmo::GizmoHandle::ScaleUniform) {
            world_size
        } else {
            1.0
        };
        self.shell.state.gizmo.drag = Some(GizmoDrag {
            handle,
            start_transform: transform,
            start_anchor,
            reference,
        });
        true
    }

    /// Updates the in-progress drag against the current cursor position,
    /// applying the new transform directly to the selected entity. The
    /// command stack is only touched on release; intermediate frames just
    /// mutate the scene so the gizmo follows the pointer fluidly.
    fn advance_gizmo_drag(&mut self, size: (u32, u32), cursor: (f32, f32)) {
        let Some(drag) = self.shell.state.gizmo.drag else {
            return;
        };
        let Some(selected) = self.shell.state.selected else {
            return;
        };
        let Some(vp) = self.viewport.as_ref() else {
            return;
        };
        let ray = vp.ray_from_cursor(cursor, size, self.shell.viewport_rect());
        // Ctrl-held → snap; the snap settings live on the editor state so
        // a future preferences page can tune the steps.
        let snap = self
            .modifiers
            .control_key()
            .then_some(&self.shell.state.gizmo.snap);
        let next = gizmo::apply_drag(&drag, &ray, snap);
        // Drag math operates in world space (start_transform was the
        // entity's *world* transform); for an entity with parents the
        // computed `next` lives in world space too, so writing it as the
        // local transform is only exact when the entity has no parent.
        // Hierarchy-aware gizmo math is a refinement for a later piece.
        self.shell.state.scene.set_local_transform(selected, next);
    }

    /// Commits the in-progress drag (if any) by pushing a `SetTransformCmd`
    /// onto the command stack and clearing the drag — making the whole
    /// drag one undo entry.
    fn end_gizmo_drag(&mut self) {
        let Some(drag) = self.shell.state.gizmo.drag.take() else {
            return;
        };
        let Some(selected) = self.shell.state.selected else {
            return;
        };
        let Some(after) = self.shell.state.scene.local_transform(selected) else {
            return;
        };
        // Skip the command when nothing actually changed (the user clicked
        // a handle but didn't drag).
        let before = drag.start_transform;
        if before == after {
            return;
        }
        self.shell.push_command(SetTransformCmd {
            entity: selected,
            before,
            after,
        });
    }

    /// Builds the per-frame [`ViewportOverlay`] the Shell's Viewport tab paints
    /// every world-space overlay with (transform gizmo, collider wireframes, the
    /// raycast probe). `None` only when the viewport isn't initialized yet — the
    /// `view_proj` is always available, so colliders/probe show without a
    /// selection; `gizmo_size` falls back to a unit when nothing is selected
    /// (the gizmo itself isn't painted then, so the value is unused there).
    fn build_gizmo_overlay(
        &self,
        size: (u32, u32),
        rect: Option<oxide_engine::math::Rect>,
    ) -> Option<oxide_editor::shell::ViewportOverlay> {
        let vp = self.viewport.as_ref()?;
        let gizmo_size = self
            .shell
            .state
            .selected
            .and_then(|e| self.shell.state.scene.world_transform(e))
            .map(|t| self.gizmo_world_size(t.translation))
            .unwrap_or(1.0);
        Some(oxide_editor::shell::ViewportOverlay {
            view_proj: vp.view_projection_for(rect, size),
            gizmo_size,
        })
    }

    /// **Freezes** a raycast probe: casts the editor camera→cursor ray against
    /// the *edited* scene's colliders right now and stores the result on the
    /// Shell so the Viewport tab keeps drawing it in world space (Stage 9 piece
    /// 8c). Because the ray is frozen into the world, orbiting the camera reveals
    /// it as a real 3D line — a ray cast from the live camera is otherwise just a
    /// point in that same camera's view. Builds a transient [`PhysicsWorld`] from
    /// the scene via [`sync_to_scene`](oxide_physics::PhysicsWorld::sync_to_scene)
    /// so the probe reflects unsaved edits without requiring Play. No-op if the
    /// cursor or viewport is unavailable or the ray is degenerate.
    fn cast_probe_ray(&mut self, size: (u32, u32)) {
        use oxide_editor::shell::{RaycastProbeHit, RaycastProbeViz};
        let rect = self.shell.viewport_rect();
        let Some(cursor) = self.last_cursor else {
            return;
        };
        let Some(vp) = self.viewport.as_ref() else {
            return;
        };
        let ray = vp.ray_from_cursor(cursor, size, rect);
        if ray.direction == oxide_engine::math::Vec3::ZERO {
            return;
        }

        const PROBE_DISTANCE: f32 = 1000.0;
        let mut world = oxide_physics::PhysicsWorld::new();
        world.sync_to_scene(&self.shell.state.scene);
        let hit = world.raycast(
            ray.origin,
            ray.direction,
            PROBE_DISTANCE,
            oxide_engine::layer::LayerMask::ALL,
        );
        // Surface a one-line result so the cast gives feedback even before the
        // user orbits to look at the frozen ray.
        match hit {
            Some(h) => {
                let name = self
                    .shell
                    .state
                    .scene
                    .name(h.entity)
                    .unwrap_or_else(|| "<entity>".to_string());
                self.shell
                    .set_status_hint(format!("Raycast probe: hit {name}"));
            }
            None => self.shell.set_status_hint("Raycast probe: miss"),
        }
        self.shell.set_raycast_probe_viz(Some(RaycastProbeViz {
            origin: ray.origin,
            end: hit
                .map(|h| h.point)
                .unwrap_or_else(|| ray.at(PROBE_DISTANCE)),
            hit: hit.map(|h| RaycastProbeHit {
                point: h.point,
                normal: h.normal,
            }),
        }));
    }

    /// Writes the editor's preferences file to disk when the shell flagged
    /// a binding edit since the last call. Logs (but does not panic on) I/O
    /// errors — losing one save is recoverable; crashing the editor is not.
    fn save_preferences_if_dirty(&mut self) {
        if !self.shell.take_bindings_dirty() {
            return;
        }
        let snapshot = self.shell.state.settings.export();
        if let Err(err) = preferences::save(&snapshot) {
            log::warn!("failed to save editor preferences: {err}");
        }
    }

    /// Drives the play-mode runtime (Stage 8.7). Reconciles the play `App`'s
    /// existence with the editor's [`PlayState`] (build it on Play, drop it on
    /// Stop), then advances the simulation as far as
    /// [`play::tick_for`](oxide_editor::play::tick_for) decides — swapping the
    /// editor scene into the `App` for the tick and back out so the rest of the
    /// editor keeps seeing `shell.state.scene`.
    ///
    /// Called unconditionally each frame, before the cursor-gated editor input,
    /// so play continues regardless of where the pointer is.
    fn drive_play(&mut self, dt: f32) {
        let in_play = self.shell.state.is_in_play();
        // Build the runtime when play starts; tear it down when it stops. The
        // engine `App` carries the default modules plus physics (Stage 9) and
        // scripting (Stage 10); the project's own modules register here too in a
        // later stage.
        if in_play && self.play_app.is_none() {
            let mut app = App::new();
            // Share the editor's asset server so the play app resolves the same
            // assets *and* the file watcher's in-place reloads (which target the
            // editor server) reach a **playing** scene — live-reloading a script
            // while the scene runs.
            app.assets = self.shell.state.assets.clone();
            app.add_modules(DefaultModules);
            app.add_module(oxide_physics::PhysicsModule);
            app.add_module(oxide_script::ScriptModule);
            // Scripts resolve their `AssetRef<ScriptAsset>` through the project's
            // asset database; hand the play app a snapshot so uids map to files.
            if let Some(db) = &self.shell.state.asset_db {
                app.insert_resource(db.clone());
            }
            self.play_app = Some(app);
        } else if !in_play && self.play_app.is_some() {
            self.play_app = None;
        }

        let tick = play::tick_for(self.shell.state.play, self.shell.take_step_request());
        if matches!(tick, Tick::Idle) {
            return;
        }
        let Some(app) = self.play_app.as_mut() else {
            return;
        };
        // Run the engine schedule against the editor's live scene, then hand it
        // back. `swap` is O(1) (two `Scene` moves), so the editor scene is only
        // "inside" the App for the duration of the tick.
        std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
        match tick {
            Tick::Frame => app.update(dt),
            Tick::FixedStep => app.step(),
            Tick::Idle => {}
        }
        std::mem::swap(&mut self.shell.state.scene, &mut app.scene);
    }

    /// Ray-picks the entity under the cursor and selects it (or clears the
    /// selection if the ray misses everything).
    fn pick_under_cursor(&mut self, size: (u32, u32)) {
        let Some(cursor) = self.last_cursor else {
            return;
        };
        let rect = self.shell.viewport_rect();
        let picked = self
            .viewport
            .as_ref()
            .and_then(|vp| vp.pick(&self.shell.state.scene, cursor, size, rect));
        self.shell.select(picked);
    }
}

impl WindowApp for EditorApp {
    fn init(&mut self, ctx: &mut AppCtx<'_>) {
        let (w, h) = ctx.size();
        let device = ctx.render().gpu().device().clone();
        let format = ctx.render().surface_format();
        let layer = EguiLayer::new(ctx.window(), &device, format);
        self.egui_layer = Some(layer);
        self.viewport = Some(Viewport::new(&device, format));
        log::info!(
            "editor window open ({w}x{h}); docking shell active \
             (L-drag: orbit · R-drag: pan · scroll: zoom · F: toggle flythrough · \
             Ctrl+Z: undo · Ctrl+Q: quit)"
        );
    }

    fn event(&mut self, ctx: &mut AppCtx<'_>, event: &WindowEvent) {
        // Let egui handle the event first (text fields, clicks, scrolling).
        // `consumed` is true when the pointer is over an egui widget, but
        // the Viewport tab is technically an egui widget too — so egui would
        // claim every click in the central area. Override that: if the cursor
        // is over the Viewport tab's rect we treat the event as ours, so
        // orbit/pan/zoom/pick work inside the dock.
        let egui_consumed = self
            .egui_layer
            .as_mut()
            .map(|layer| layer.on_window_event(ctx.window(), event))
            .unwrap_or(false);
        // A floating panel (egui Window) can overlap the viewport rect; when
        // the pointer is over one, the click belongs to egui, not the 3D view —
        // otherwise we'd drag the panel and orbit the camera at the same time.
        let over_floating = self
            .egui_layer
            .as_ref()
            .map(|layer| layer.pointer_over_floating())
            .unwrap_or(false);
        let over_viewport = !over_floating
            && self
                .last_cursor
                .map(|c| self.shell.cursor_over_viewport(c))
                .unwrap_or(false);
        let consumed = egui_consumed && !over_viewport;

        match event {
            WindowEvent::ModifiersChanged(modifiers) => {
                self.modifiers = modifiers.state();
                // If a gizmo drag is in flight, re-apply it with the new
                // modifier state so toggling Ctrl mid-drag snaps (or
                // unsnaps) the current position immediately — even when
                // the mouse hasn't moved since.
                if self.shell.state.gizmo.drag.is_some() {
                    if let Some(cursor) = self.last_cursor {
                        self.advance_gizmo_drag(ctx.size(), cursor);
                    }
                }
            }
            WindowEvent::KeyboardInput { event, .. } => {
                if event.state != ElementState::Pressed {
                    return;
                }
                let ctrl = self.modifiers.control_key();
                let shift = self.modifiers.shift_key();
                // Engine-global Ctrl+Q is handled here; everything else is
                // delegated to the shell so the same shortcut routing is
                // exercised by tests.
                if ctrl && event.physical_key == PhysicalKey::Code(KeyCode::KeyQ) {
                    log::info!("Ctrl+Q — exiting editor");
                    ctx.request_exit();
                    return;
                }
                if ctrl {
                    let ch = match event.physical_key {
                        PhysicalKey::Code(KeyCode::KeyZ) => Some('z'),
                        PhysicalKey::Code(KeyCode::KeyY) => Some('y'),
                        PhysicalKey::Code(KeyCode::KeyS) => Some('s'),
                        PhysicalKey::Code(KeyCode::Comma) => Some(','),
                        PhysicalKey::Code(KeyCode::KeyP) => Some('p'),
                        PhysicalKey::Code(KeyCode::Period) => Some('.'),
                        _ => None,
                    };
                    if let Some(ch) = ch {
                        self.shell.try_consume_shortcut(true, shift, Some(ch));
                    }
                }
            }
            WindowEvent::MouseInput { state, button, .. } => {
                let pressed = *state == ElementState::Pressed;
                match button {
                    MouseButton::Left => {
                        if pressed {
                            // Try a gizmo handle first — if the click hit
                            // one, we start a drag instead of orbiting.
                            let on_gizmo = !consumed && self.try_begin_gizmo_drag(ctx.size());
                            self.orbiting = !consumed && !on_gizmo;
                            self.left_drag_dist = 0.0;
                        } else {
                            // Release: commit the gizmo drag if any (one
                            // SetTransformCmd per drag = one undo entry).
                            if self.shell.state.gizmo.drag.is_some() {
                                self.end_gizmo_drag();
                            } else if self.orbiting && self.left_drag_dist < 4.0 {
                                // Click without drag → pick, and (if the raycast
                                // probe is on) freeze a debug ray into the world
                                // so it can be inspected by orbiting the camera.
                                self.pick_under_cursor(ctx.size());
                                if self.shell.raycast_probe_enabled() {
                                    self.cast_probe_ray(ctx.size());
                                }
                            }
                            self.orbiting = false;
                        }
                    }
                    MouseButton::Right | MouseButton::Middle => self.panning = pressed && !consumed,
                    _ => {}
                }
            }
            WindowEvent::CursorMoved { position, .. } => {
                let pos = (position.x as f32, position.y as f32);
                if let Some((lx, ly)) = self.last_cursor {
                    let (dx, dy) = (pos.0 - lx, pos.1 - ly);
                    if self.orbiting {
                        self.left_drag_dist += dx.abs() + dy.abs();
                    }

                    // If a gizmo drag is in flight, route the move into the
                    // gizmo math and skip the camera controls entirely.
                    if self.shell.state.gizmo.drag.is_some() {
                        self.advance_gizmo_drag(ctx.size(), pos);
                    } else if let Some(vp) = self.viewport.as_mut() {
                        match vp.mode {
                            CameraMode::Orbit => {
                                if self.orbiting {
                                    vp.orbit.orbit(dx, dy);
                                } else if self.panning {
                                    vp.orbit.pan(dx, dy);
                                }
                            }
                            CameraMode::Flythrough => {
                                // In flythrough the existing right-drag gesture
                                // becomes mouse-look; left-drag is a no-op for
                                // the camera (click-without-drag still picks).
                                if self.panning {
                                    vp.flythrough.look(dx, dy);
                                }
                            }
                        }
                    }
                }
                self.last_cursor = Some(pos);
            }
            WindowEvent::MouseWheel { delta, .. } if !consumed => {
                let amount = match delta {
                    MouseScrollDelta::LineDelta(_, y) => *y,
                    MouseScrollDelta::PixelDelta(p) => p.y as f32 / 40.0,
                };
                if let Some(vp) = self.viewport.as_mut() {
                    // Scroll has different roles per mode: zoom-in/out for the
                    // orbit subject, faster/slower travel for the flythrough.
                    match vp.mode {
                        CameraMode::Orbit => vp.orbit.zoom(amount),
                        CameraMode::Flythrough => vp.flythrough.adjust_move_speed(amount),
                    }
                }
            }
            _ => {}
        }
    }

    fn update(&mut self, ctx: &mut AppCtx<'_>) {
        // Drain the file watcher into AssetServer::reload_path and age out
        // the status bar's last hint.
        self.shell.frame_tick();
        // Propagate File → Quit (the shell can't reach the runner directly).
        if self.shell.take_quit_request() {
            ctx.request_exit();
        }

        // Advance the play-mode simulation (if any) every frame, before the
        // cursor-gated editor input below — play must not depend on the pointer
        // being over the viewport.
        self.drive_play(ctx.dt);

        // Bindings preferences page — when a capture is in progress, consume
        // the next pressed key/button into the targeted slot. Runs before
        // any other input poll so the captured press doesn't double-fire
        // a normal action.
        let input = ctx.input();
        if self.shell.capture_active() {
            self.shell.try_complete_capture(input);
            // Flush to disk if the capture committed a binding.
            self.save_preferences_if_dirty();
            return;
        }

        // Editor input — polled per-frame from the Stage-7 InputState. Only
        // fires when the cursor is over the Viewport tab so the same keys do
        // not steal focus from a search box or text field elsewhere.
        let over_floating = self
            .egui_layer
            .as_ref()
            .map(|layer| layer.pointer_over_floating())
            .unwrap_or(false);
        let cursor_over_vp = !over_floating
            && self
                .last_cursor
                .map(|c| self.shell.cursor_over_viewport(c))
                .unwrap_or(false);
        if !cursor_over_vp {
            // Even off the viewport, a "Restore defaults" click from the
            // preferences UI marks the bindings dirty — flush here.
            self.save_preferences_if_dirty();
            return;
        }

        let actions = &self.shell.state.actions;
        if actions.action_pressed(action::TOGGLE_FLYTHROUGH, input) {
            if let Some(vp) = self.viewport.as_mut() {
                let new_mode = vp.toggle_camera_mode();
                let label = match new_mode {
                    CameraMode::Orbit => "Camera: Orbit (L-drag orbit · R-drag pan · scroll zoom)",
                    CameraMode::Flythrough => {
                        "Camera: Flythrough (WASD/QE move · Shift sprint · R-drag look · scroll speed)"
                    }
                };
                log::info!("{label}");
                self.shell.set_status_hint(label);
            }
        }

        if let Some(vp) = self.viewport.as_mut() {
            if vp.mode == CameraMode::Flythrough {
                let actions = &self.shell.state.actions;
                let right = actions.axis(action::MOVE_RIGHT, input);
                let forward = actions.axis(action::MOVE_FORWARD, input);
                let up = actions.axis(action::MOVE_UP, input);
                // The camera's translate_local takes (right, up, -forward),
                // i.e. -Z is camera-forward, mirroring the OrbitCamera's
                // looking_at convention.
                let local = oxide_engine::math::Vec3::new(right, up, -forward);
                let sprint = actions.action_held(action::SPRINT, input);
                vp.flythrough.translate_local(local, ctx.dt, sprint);
            }
        }

        // Gizmo tool hotkeys (W/E/R by default). Share keys with flythrough
        // movement, so they only fire in orbit mode — in flythrough WASD
        // moves the camera.
        let orbit_mode = matches!(
            self.viewport.as_ref().map(|v| v.mode),
            Some(CameraMode::Orbit)
        );
        if orbit_mode {
            let actions = &self.shell.state.actions;
            let new_mode = if actions.action_pressed(action::GIZMO_TRANSLATE, input) {
                Some(GizmoMode::Translate)
            } else if actions.action_pressed(action::GIZMO_ROTATE, input) {
                Some(GizmoMode::Rotate)
            } else if actions.action_pressed(action::GIZMO_SCALE, input) {
                Some(GizmoMode::Scale)
            } else {
                None
            };
            if let Some(mode) = new_mode {
                self.shell.state.gizmo.mode = mode;
                log::info!("Gizmo tool: {}", mode.label());
                self.shell
                    .set_status_hint(format!("Gizmo: {}", mode.label()));
            }
        }

        self.save_preferences_if_dirty();
    }

    fn render(&mut self, ctx: &RenderCtx<'_>) {
        // Draw the 3D scene first; egui then composites its panels on top
        // (both record with `LoadOp::Load` over the engine's clear). Taken out
        // and back so the immutable scene borrow doesn't clash with `&mut self`.
        let rect = self.shell.viewport_rect();
        if let Some(mut vp) = self.viewport.take() {
            vp.render(&self.shell.state.scene, ctx, rect);
            self.viewport = Some(vp);
        }

        // Hand the Shell the data its Viewport tab needs to paint the gizmo
        // overlay using the same projection the scene was drawn with.
        self.shell
            .set_viewport_overlay(self.build_gizmo_overlay(ctx.size, rect));

        let Some(mut layer) = self.egui_layer.take() else {
            return;
        };
        let shell = &mut self.shell;
        layer.paint(
            ctx.window,
            ctx.gpu.device(),
            ctx.gpu.queue(),
            ctx.view,
            ctx.size,
            |ui| shell.build(ui),
        );
        self.egui_layer = Some(layer);
    }
}

fn main() -> anyhow::Result<()> {
    // Install the capturing logger so script output/errors also reach the
    // editor Console panel (still prints to the terminal, honours RUST_LOG).
    oxide_editor::console::init();
    log::info!("Oxide Editor starting…");

    let config = WindowConfig {
        title: "Oxide Editor".to_string(),
        clear_color: VIEWPORT_CLEAR,
        ..Default::default()
    };
    run(config, EditorApp::new())
}
