khora_core/ui/editor/state.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Editor state shared between the application logic and UI panels.
16//!
17//! The [`EditorState`] is populated by `Application::update()` every frame
18//! with a lightweight snapshot of the ECS world. Editor panels read this
19//! snapshot through a shared `Arc<Mutex<EditorState>>` retrieved from the
20//! `ServiceRegistry`.
21
22use crate::ecs::entity::EntityId;
23use std::collections::HashSet;
24
25/// The active gizmo tool in the viewport toolbar.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum GizmoMode {
28 /// Selection / pointer tool.
29 #[default]
30 Select,
31 /// Translation gizmo.
32 Move,
33 /// Rotation gizmo.
34 Rotate,
35 /// Scale gizmo.
36 Scale,
37}
38
39/// The play-mode state of the editor.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum PlayMode {
42 /// Normal editing mode — scene is static.
43 #[default]
44 Editing,
45 /// Game simulation is running.
46 Playing,
47 /// Game simulation is paused (can resume or stop).
48 Paused,
49}
50
51/// The active editing workspace, switched via the left "spine" mode bar.
52///
53/// Two workspaces ship today: `Scene` (the default 3D dock) and `ControlPlane`
54/// (the DCC / agents inspector). Future authoring workspaces (2D canvas, node
55/// graph, animation, shader graph) will be added as they are built.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
57pub enum EditorMode {
58 /// Default 3D viewport workspace.
59 #[default]
60 Scene,
61 /// Dynamic Context Core / agents control workspace.
62 ControlPlane,
63}
64
65/// A lightweight description of an entity in the scene tree.
66///
67/// This is a UI-oriented DTO extracted from the ECS world each frame.
68/// It does not borrow any ECS data and can be freely shared between threads.
69#[derive(Debug, Clone)]
70pub struct SceneNode {
71 /// The entity identifier.
72 pub entity: EntityId,
73 /// Human-readable name (`Name` component, or fallback like "Entity 42").
74 pub name: String,
75 /// Visual hint about what kind of entity this is.
76 pub icon: EntityIcon,
77 /// Direct children in the scene hierarchy (from the `Children` component).
78 pub children: Vec<SceneNode>,
79 /// Number of tags carried by the entity (`Tag` component). The scene
80 /// tree paints a small tag glyph next to the row when this is non-zero.
81 pub tag_count: usize,
82}
83
84/// Icon hint for the scene tree.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum EntityIcon {
87 /// Generic / unknown entity.
88 Empty,
89 /// Entity has a `Camera` component.
90 Camera,
91 /// Entity has a `Light` component.
92 Light,
93 /// Entity has a mesh handle.
94 Mesh,
95 /// Entity has an `AudioSource` component.
96 Audio,
97}
98
99/// Shared editor state populated by `Application::update()` each frame.
100///
101/// Panels read this to display the scene tree, selection highlights, etc.
102/// All data is owned (snapshot) — no ECS borrows.
103#[derive(Debug, Clone, Default)]
104pub struct EditorState {
105 /// Root-level scene nodes (entities without a `Parent` component).
106 pub scene_roots: Vec<SceneNode>,
107 /// Currently selected entity IDs.
108 pub selection: HashSet<EntityId>,
109 /// Total entity count in the world.
110 pub entity_count: usize,
111 /// Scene tree search / filter text.
112 pub search_filter: String,
113 /// Whether the Ctrl key is currently held (for multi-select).
114 pub ctrl_held: bool,
115 /// A pending spawn request tag (e.g. "Empty", "Cube", "Light", "Camera").
116 /// The application reads and clears this each frame.
117 pub pending_spawn: Option<String>,
118 /// Pending delete request for a specific entity (from context menu).
119 pub pending_delete: Option<EntityId>,
120 /// Pending duplicate request for a specific entity (from context menu).
121 pub pending_duplicate: Option<EntityId>,
122 /// Entity currently being renamed (inline text editing).
123 pub renaming_entity: Option<EntityId>,
124 /// Buffer for the rename text input.
125 pub rename_buffer: String,
126 /// Pending rename to apply: (entity, new_name).
127 pub pending_rename: Option<(EntityId, String)>,
128 /// Pending reparent to apply: (child, new_parent_or_None_for_root).
129 /// Set by the scene tree drag-and-drop handler, drained by
130 /// `process_reparents` in the editor's tick.
131 pub pending_reparent: Option<(EntityId, Option<EntityId>)>,
132
133 // ── Phase 4: Properties Inspector ──────────────────
134 /// Snapshot of the single-selected entity's components (if any).
135 pub inspected: Option<InspectedEntity>,
136 /// Pending property edits to apply back to the ECS world.
137 pub pending_edits: Vec<PropertyEdit>,
138
139 // ── Phase 5: Console / Status Bar ──────────────────
140 /// Log entries captured by the editor log sink.
141 pub log_entries: Vec<LogEntry>,
142 /// Status bar data (FPS, entity count, memory).
143 pub status: StatusBarData,
144 /// Asset entries for the asset browser (populated from the VFS).
145 pub asset_entries: Vec<AssetEntry>,
146
147 // ── Phase 6: Viewport interaction + Gizmo ──────────
148 /// Whether the 3D viewport is currently hovered (for camera controls).
149 /// Updated each frame from `is_last_item_hovered()` after the viewport
150 /// image is laid out — only meaningful for the current paint pass; UI
151 /// code that reads input handlers should prefer `viewport_screen_rect`
152 /// + the live cursor position.
153 pub viewport_hovered: bool,
154 /// Screen-space rect of the 3D viewport image (`[x, y, w, h]` in pixels)
155 /// for the current frame. `None` when the editor isn't in Scene mode or
156 /// the viewport hasn't been laid out yet. Used by the input pipeline to
157 /// decide if a mouse event lives over the viewport — checked against
158 /// the live cursor position so it doesn't suffer from the
159 /// frame-of-latency `viewport_hovered` had.
160 pub viewport_screen_rect: Option<[f32; 4]>,
161 /// The active gizmo tool.
162 pub gizmo_mode: GizmoMode,
163 /// Index of the currently selected asset in the asset browser (if any).
164 pub selected_asset: Option<usize>,
165 /// Forward-slash relative path (under `<project>/assets/`) of the
166 /// asset selected in the browser, or `None`. When set, the
167 /// Inspector switches to asset-metadata mode (Phase 5). Cleared
168 /// when an entity selection is made.
169 pub inspected_asset_path: Option<String>,
170 /// Pending menu action (e.g. "new_scene", "save", "quit").
171 pub pending_menu_action: Option<String>,
172 /// Currently set project folder (used for asset scanning).
173 pub project_folder: Option<String>,
174 /// Human-readable project name (read from `project.json`).
175 pub project_name: Option<String>,
176 /// Engine version string read from `project.json::engine_version` (set
177 /// by the hub at project creation time). The status bar and command
178 /// palette display this so users see the engine they targeted, not the
179 /// editor binary's own crate version.
180 pub project_engine_version: Option<String>,
181 /// Whether the asset browser should open a folder picker next frame.
182 pub pending_browse_project_folder: bool,
183
184 // ── Phase 7: Play Mode ─────────────────────────────
185 /// Current play mode (Editing / Playing / Paused).
186 pub play_mode: PlayMode,
187 /// Serialised snapshot of the scene taken when entering play mode.
188 /// Restored when the user presses Stop.
189 pub scene_snapshot: Option<Vec<u8>>,
190
191 // ── Scene file path ──────────────────────────────
192 /// Path to the currently open scene file (for Save).
193 pub current_scene_path: Option<String>,
194 /// Pending scene load path (set by asset browser double-click, consumed by update).
195 pub pending_scene_load: Option<String>,
196
197 // ── Component addition ─────────────────────────────
198 /// Pending component addition (set by properties panel or scene tree context menu, consumed by update).
199 /// The String is the component type name (e.g., "Camera", "RigidBody").
200 pub pending_add_component: Option<(EntityId, String)>,
201
202 /// Registry mapping `type_name` → domain tag for every component the
203 /// engine currently knows about. Populated by `extract_inspected` once
204 /// per frame from the live `World`. Used by the inspector to bucket
205 /// the "+ Add Component" menu by domain without re-querying the world.
206 pub component_domain_registry: std::collections::HashMap<String, u8>,
207
208 // ── Editor workspace ───────────────────────────────
209 /// Currently active editing workspace (scene / control plane / …).
210 pub active_mode: EditorMode,
211 /// Whether the command palette modal is open.
212 pub command_palette_open: bool,
213 /// Inspector card expand/collapse state, keyed by stable card id
214 /// (typically `"<entity_index>::<title>"` to avoid cross-entity bleed).
215 pub inspector_card_open: std::collections::HashMap<String, bool>,
216 /// Inspector card on/off toggle state. UI-only for now (no engine wiring).
217 pub inspector_card_enabled: std::collections::HashMap<String, bool>,
218 /// Current git branch name read from `.git/HEAD` of the project folder.
219 /// `None` if the project isn't a git repository or we couldn't read it.
220 pub current_git_branch: Option<String>,
221 /// Entities the user has hidden via the eye icon in the scene tree.
222 /// UI-only state today — see `pending_visibility_toggle` for the engine
223 /// hook the editor consumes each frame.
224 pub hidden_entities: HashSet<EntityId>,
225 /// Pending visibility toggle from the scene tree eye icon. The editor
226 /// pops this each frame and applies it to the engine (when an engine
227 /// `Visible` component lands; for now it just flips `hidden_entities`).
228 pub pending_visibility_toggle: Option<EntityId>,
229
230 // ── Prefab workflow ────────────────────────────────
231 /// Set when the user picks "Save as Prefab" in the scene tree
232 /// context menu. The editor consumes this next frame, opens a
233 /// file dialog, calls `serialize_subtree`, and writes the resulting
234 /// `.kprefab` file.
235 pub pending_save_as_prefab: Option<EntityId>,
236 /// Same payload as `pending_save_as_prefab`, but with a pre-chosen
237 /// destination forward-slash relative path under `<project>/assets/`
238 /// — set by drag-and-drop (e.g. dragging an entity onto the asset
239 /// browser's current folder). The dispatcher writes directly with
240 /// no file dialog and reuses the entity name as the file stem when
241 /// the path ends in `/`.
242 pub pending_save_as_prefab_at: Option<(EntityId, String)>,
243 /// Set when the user drops a `.kprefab` tile onto the viewport / hierarchy
244 /// (or activates one from the asset browser). Holds the forward-slash
245 /// relative path under `<project>/assets/` plus an optional parent entity
246 /// (the hierarchy row it was dropped on) so the instantiated root is
247 /// parented under it. Consumed next frame to load the recipe via the asset
248 /// service and call `instantiate_subtree`.
249 pub pending_prefab_spawn: Option<(String, Option<EntityId>)>,
250
251 // ── Material authoring workflow ─────────────────────
252 /// Set when the user picks "Save Material as .kmat" on an entity that
253 /// carries a `MaterialRef::Inline`. Holds the entity plus the chosen
254 /// material name (file stem). The editor consumes this next frame:
255 /// serializes the inline material to RON under
256 /// `assets/materials/<name>.kmat`, reindexes, and rewrites the entity's
257 /// component to `MaterialRef::Asset(uuid)` so it now references the
258 /// shared, reloadable asset.
259 pub pending_save_as_material: Option<(EntityId, String)>,
260 /// Set when the user assigns a `.kmat` from the asset browser to the
261 /// current selection. Holds the forward-slash relative path of the
262 /// `.kmat` under `<project>/assets/`. Consumed next frame: each
263 /// selected entity's `MaterialRef` is set to `Asset(uuid)`.
264 pub pending_assign_material: Option<String>,
265
266 // ── Asset explorer: file operations & scene drop ───
267 /// Every directory under `assets/` (forward-slash, relative), so the asset
268 /// browser can show empty folders the file-only VFS can't. Refreshed with
269 /// `asset_entries`.
270 pub asset_dirs: Vec<String>,
271 /// Bumped whenever `asset_entries`/`asset_dirs` change. The asset browser
272 /// rescans its flattened cache on epoch change instead of on entry-count
273 /// change (a modified-in-place file used to be missed).
274 pub asset_epoch: u64,
275 /// Create an empty folder at this forward-slash relative path under
276 /// `assets/`. Consumed next frame.
277 pub pending_create_folder: Option<String>,
278 /// Rename/move an asset: `(old_rel, new_rel)`, both forward-slash under
279 /// `assets/`. The identity registry freezes the UUID so references survive.
280 pub pending_rename_asset: Option<(String, String)>,
281 /// Move an asset into a folder: `(src_rel, dest_dir)` (dest_dir `""` = root).
282 pub pending_move_asset: Option<(String, String)>,
283 /// Send an asset to the OS recycle bin: forward-slash relative path.
284 pub pending_delete_asset: Option<String>,
285 /// Duplicate an asset next to itself: forward-slash relative path.
286 pub pending_duplicate_asset: Option<String>,
287 /// Spawn a mesh asset into the scene: `(rel_path, [x,y,z] world point,
288 /// optional parent entity)`. Set by dragging a mesh tile onto the viewport
289 /// (parent `None`) or a hierarchy row (parent = that entity); drained into a
290 /// `MeshRef::Asset` entity spawn, parented under the target when present.
291 pub pending_spawn_mesh_asset: Option<(String, [f32; 3], Option<EntityId>)>,
292 /// Assign a texture/material asset to a specific entity (drag onto an entity
293 /// in the viewport): `(rel_path, entity)`.
294 pub pending_assign_texture: Option<(String, EntityId)>,
295}
296
297impl EditorState {
298 /// Returns `true` if the entity is currently selected.
299 pub fn is_selected(&self, entity: EntityId) -> bool {
300 self.selection.contains(&entity)
301 }
302
303 /// Select a single entity (clears previous selection).
304 pub fn select(&mut self, entity: EntityId) {
305 self.selection.clear();
306 self.selection.insert(entity);
307 }
308
309 /// Toggle an entity in the selection (Ctrl+click behavior).
310 pub fn toggle_select(&mut self, entity: EntityId) {
311 if !self.selection.remove(&entity) {
312 self.selection.insert(entity);
313 }
314 }
315
316 /// Clear the selection entirely.
317 pub fn clear_selection(&mut self) {
318 self.selection.clear();
319 }
320
321 /// Drops every piece of state that names an [`EntityId`], for use whenever
322 /// the world is replaced wholesale — new scene, scene load, or `Stop`
323 /// restoring the pre-play snapshot.
324 ///
325 /// Those paths rebuild the world with **fresh** ids, so anything still
326 /// holding an old one is not merely stale but actively dangerous: entity
327 /// slots are recycled, and an index+generation pair can come back attached
328 /// to a different entity. A surviving selection then makes `Delete` act on
329 /// something the user never selected.
330 ///
331 /// Card state is keyed by entity too, so it is cleared here rather than
332 /// growing without bound across scene changes.
333 pub fn clear_entity_references(&mut self) {
334 self.selection.clear();
335 self.inspected = None;
336 self.hidden_entities.clear();
337 self.renaming_entity = None;
338 self.rename_buffer.clear();
339 self.inspector_card_open.clear();
340 self.inspector_card_enabled.clear();
341 self.scene_roots.clear();
342 self.entity_count = 0;
343 }
344
345 /// Returns the single selected entity, if exactly one is selected.
346 pub fn single_selected(&self) -> Option<EntityId> {
347 if self.selection.len() == 1 {
348 self.selection.iter().next().copied()
349 } else {
350 None
351 }
352 }
353
354 /// Push a property edit to be applied back to the ECS world next frame.
355 pub fn push_edit(&mut self, edit: PropertyEdit) {
356 self.pending_edits.push(edit);
357 }
358
359 /// Drain all pending edits (called by `Application::update()`).
360 pub fn drain_edits(&mut self) -> Vec<PropertyEdit> {
361 std::mem::take(&mut self.pending_edits)
362 }
363}
364
365// ════════════════════════════════════════════════════════
366// Properties Inspector types
367// ════════════════════════════════════════════════════════
368
369/// Snapshot of one component on an inspected entity, captured generically
370/// as JSON via the macro-generated `to_json` on `ComponentRegistration`.
371///
372/// This is the path adding a new component costs **zero** editor code: any
373/// type that derives `Component` is auto-registered and shows up here.
374/// Components with a hard-coded inspector card (Transform, Camera, etc.)
375/// are skipped from the generic list to avoid double-rendering.
376#[derive(Debug, Clone)]
377pub struct ComponentJson {
378 /// `ComponentRegistration::type_name` — card title and lookup key.
379 pub type_name: String,
380 /// Domain bucket, mirrored from `SemanticDomain` (Spatial=0, Render=1,
381 /// Audio=2, Physics=3, Ui=4). `None` means the type isn't registered
382 /// on a CRPECS page (e.g. types registered via inventory only).
383 pub domain: Option<u8>,
384 /// Live JSON value. The shape mirrors the component's
385 /// `Serializable<Type>` form.
386 pub value: serde_json::Value,
387}
388
389/// Snapshot of an inspected entity.
390///
391/// Components are captured generically as JSON via the macro-generated
392/// `ComponentRegistration::to_json`. The inspector iterates `components_json`
393/// and renders every entry through a single field-typed walker — there is
394/// no per-component code path. The entity's `Name` lives in the inspector
395/// header rather than as a component card.
396#[derive(Debug, Clone)]
397#[allow(missing_docs)]
398pub struct InspectedEntity {
399 pub entity: EntityId,
400 pub name: String,
401 /// Every component on this entity, captured as JSON. The inspector
402 /// renders this list directly — no per-component hard-coding.
403 pub components_json: Vec<ComponentJson>,
404}
405
406/// A property edit to apply back to the ECS world.
407///
408/// Components are mutated through a single generic round-trip via
409/// `ComponentRegistration::from_json`: the inspector walks each field of
410/// the component's JSON shape, lets the user edit it, and ships the whole
411/// patched `serde_json::Value` back through `SetComponentJson`. There is
412/// no per-component editor variant — adding a new component costs zero
413/// editor code.
414#[derive(Debug, Clone)]
415#[allow(missing_docs)]
416pub enum PropertyEdit {
417 /// Rename the entity (`Name` component is special — it shows up in the
418 /// inspector header rather than as a component card).
419 SetName(EntityId, String),
420 /// Replace the entire JSON value of one component on `entity`. The
421 /// editor looks up the registration by `type_name` and calls
422 /// `from_json` to commit.
423 SetComponentJson {
424 entity: EntityId,
425 type_name: String,
426 value: serde_json::Value,
427 },
428 /// Remove a component from `entity`. The editor looks up the
429 /// registration by `type_name` and calls `remove` to commit.
430 RemoveComponent { entity: EntityId, type_name: String },
431}
432
433// ════════════════════════════════════════════════════════
434// Console / Status Bar types
435// ════════════════════════════════════════════════════════
436
437/// A captured log entry for the console panel.
438#[derive(Debug, Clone)]
439#[allow(missing_docs)]
440pub struct LogEntry {
441 pub level: LogLevel,
442 pub message: String,
443 pub target: String,
444 /// Wall-clock time the entry was captured, as `HH:MM:SS`.
445 ///
446 /// Formatted at capture rather than stored as an instant: the console is
447 /// the only consumer, it always renders it, and doing it once at capture
448 /// keeps the paint path free of formatting work.
449 pub time: String,
450}
451
452/// Log severity matching `log::Level`.
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454#[allow(missing_docs)]
455pub enum LogLevel {
456 Error,
457 Warn,
458 Info,
459 Debug,
460 Trace,
461}
462
463/// Status bar data displayed at the bottom of the editor.
464///
465/// Populated each frame by `EditorApp::update`; the GPU-related fields are
466/// pulled from `TelemetryService` when available, otherwise stay at 0.
467#[derive(Debug, Clone)]
468#[allow(missing_docs)]
469pub struct StatusBarData {
470 pub fps: f32,
471 pub frame_time_ms: f32,
472 pub entity_count: usize,
473 pub memory_used_mb: f32,
474 /// GPU draw calls last frame (from `GpuReport`). 0 when telemetry has no
475 /// reading yet.
476 pub draw_calls: u32,
477 /// GPU triangles rendered last frame.
478 pub triangles: u64,
479 /// Approximate VRAM use in MB. 0 if not reported.
480 pub vram_mb: f32,
481 /// CPU load (0.0–1.0) as reported by `HardwareReport`.
482 pub cpu_load: f32,
483 /// GPU load (0.0–1.0) as reported by `HardwareReport`.
484 pub gpu_load: f32,
485}
486
487impl Default for StatusBarData {
488 fn default() -> Self {
489 Self {
490 fps: 0.0,
491 frame_time_ms: 0.0,
492 entity_count: 0,
493 memory_used_mb: 0.0,
494 draw_calls: 0,
495 triangles: 0,
496 vram_mb: 0.0,
497 cpu_load: 0.0,
498 gpu_load: 0.0,
499 }
500 }
501}
502
503// ════════════════════════════════════════════════════════
504// Asset Browser types
505// ════════════════════════════════════════════════════════
506
507/// A lightweight description of an asset for the asset browser panel.
508#[derive(Debug, Clone)]
509pub struct AssetEntry {
510 /// Human-readable file/asset name.
511 pub name: String,
512 /// Asset type string (e.g. "Mesh", "Texture", "Shader", "Audio").
513 pub asset_type: String,
514 /// Source path (for display purposes).
515 pub source_path: String,
516}