Skip to main content

khora_editor/panels/
scene_tree.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//! Scene Tree panel — the entity hierarchy: search, foldable rows with a
16//! chevron and a type icon, and the branded gold selection bar.
17//!
18//! `EditorState::hidden_entities` still dims the rows it names, but nothing
19//! populates it: the visibility eye was removed because it only greyed the row
20//! while the object kept rendering. The set is left in place as the seam a
21//! component-activation model would plug into.
22
23use std::sync::{Arc, Mutex};
24
25use khora_sdk::editor_ui::*;
26
27use crate::widgets::chrome::paint_panel_header;
28use crate::widgets::paint::{paint_hairline_h, paint_icon, paint_text_size, with_alpha};
29
30/// Filters a `SceneNode` against a lowercase needle. Returns `Some(node)`
31/// when the node's own name contains the needle (subtree kept intact) or
32/// when any descendant matches (only matching descendants kept). Returns
33/// `None` when neither the node nor any descendant matches.
34fn filter_scene_node(node: &SceneNode, needle: &str) -> Option<SceneNode> {
35    if node.name.to_lowercase().contains(needle) {
36        return Some(node.clone());
37    }
38    let kept: Vec<SceneNode> = node
39        .children
40        .iter()
41        .filter_map(|c| filter_scene_node(c, needle))
42        .collect();
43    if kept.is_empty() {
44        None
45    } else {
46        Some(SceneNode {
47            children: kept,
48            ..node.clone()
49        })
50    }
51}
52
53/// Recursively counts every visible node in a subtree (self + children).
54fn count_scene_nodes(nodes: &[SceneNode]) -> usize {
55    nodes
56        .iter()
57        .map(|n| 1 + count_scene_nodes(&n.children))
58        .sum()
59}
60
61const ROW_HEIGHT: f32 = 26.0;
62const HEADER_HEIGHT: f32 = 34.0;
63const TOOLBAR_HEIGHT: f32 = 32.0;
64const ROW_PAD_X: f32 = 8.0;
65
66pub struct SceneTreePanel {
67    state: Arc<Mutex<EditorState>>,
68    theme: UiTheme,
69    /// How far the entity list is scrolled.
70    scroll: khora_tool_ui::widgets::ScrollState,
71    /// Entities whose subtree is folded away.
72    ///
73    /// Stored as the *exception* — collapsed rather than expanded — so a
74    /// freshly loaded scene shows its whole hierarchy, and a newly spawned
75    /// child appears without the user having to open anything.
76    collapsed: std::collections::HashSet<khora_sdk::prelude::ecs::EntityId>,
77    /// Whether the rename field already took focus for the current rename, so
78    /// it is requested once rather than every frame (which would trap it).
79    rename_focused: bool,
80}
81
82/// Finds an entity's display name in a `SceneNode` forest.
83fn find_node_name(
84    nodes: &[SceneNode],
85    entity: khora_sdk::prelude::ecs::EntityId,
86) -> Option<String> {
87    for node in nodes {
88        if node.entity == entity {
89            return Some(node.name.clone());
90        }
91        if let Some(found) = find_node_name(&node.children, entity) {
92            return Some(found);
93        }
94    }
95    None
96}
97
98impl SceneTreePanel {
99    pub fn new(state: Arc<Mutex<EditorState>>, theme: UiTheme) -> Self {
100        Self {
101            state,
102            theme,
103            scroll: khora_tool_ui::widgets::ScrollState::default(),
104            collapsed: std::collections::HashSet::new(),
105            rename_focused: false,
106        }
107    }
108}
109
110fn entity_icon(kind: EntityIcon) -> Icon {
111    match kind {
112        EntityIcon::Camera => Icon::Camera,
113        EntityIcon::Light => Icon::Light,
114        EntityIcon::Mesh => Icon::Cube,
115        EntityIcon::Audio => Icon::Music,
116        EntityIcon::Empty => Icon::Folder,
117    }
118}
119
120impl EditorPanel for SceneTreePanel {
121    fn id(&self) -> &str {
122        "khora.editor.scene_tree"
123    }
124    fn title(&self) -> &str {
125        "Hierarchy"
126    }
127
128    fn preferred_size(&self) -> Option<f32> {
129        Some(280.0)
130    }
131
132    fn ui(&mut self, ui: &mut dyn UiBuilder) {
133        let theme = self.theme.clone();
134        let panel_rect = ui.panel_rect();
135        let [px, py, pw, _] = panel_rect;
136
137        // ── Header strip ──────────────────────────────
138        paint_panel_header(ui, panel_rect, HEADER_HEIGHT, &theme);
139
140        // Lock the editor state once and hold it through the whole panel.
141        // The search field needs `&mut state.search_filter` for `text_edit_singleline`,
142        // so the lock must outlive that closure — and it's cheaper to just
143        // hold it for the full body than to reacquire it three times.
144        let mut state_guard = match self.state.lock() {
145            Ok(s) => s,
146            Err(_) => return,
147        };
148
149        let total_count = state_guard.entity_count;
150
151        // Action icons live on the right; we always keep them visible because
152        // they hold the only entry point for "+" / filter. Tabs adapt around
153        // the remaining space — Layers/Tags drop out first when cramped.
154        // Only "+" survives, and it does something. The `More` and `Filter`
155        // icons painted a hover highlight and discarded the click — the search
156        // field below already filters, and `More` duplicated the row context
157        // menu. An icon that lights up and does nothing costs more than it
158        // saves.
159        let icons_total_w = 30.0;
160        let icons_left = px + pw - icons_total_w;
161        let spawn_from_menu: std::cell::Cell<Option<String>> = std::cell::Cell::new(None);
162
163        let tab_x = px + 6.0;
164        let tab_y = py + (HEADER_HEIGHT - 22.0) * 0.5;
165        // Single tab today — Layers and Tags were decorative and removed
166        // until they're actually wired (filtered scene views per layer,
167        // tag-based selection, etc.).
168        // Filter — applied to scene_roots before rendering.
169        let filter_lower = state_guard.search_filter.to_lowercase();
170        let filter_active = !filter_lower.is_empty();
171        let filtered_roots: Vec<SceneNode> = if filter_active {
172            state_guard
173                .scene_roots
174                .iter()
175                .filter_map(|n| filter_scene_node(n, &filter_lower))
176                .collect()
177        } else {
178            state_guard.scene_roots.clone()
179        };
180        let visible_count = if filter_active {
181            count_scene_nodes(&filtered_roots)
182        } else {
183            total_count
184        };
185
186        let badge = if filter_active {
187            format!("{}/{}", visible_count, total_count)
188        } else {
189            format!("{}", total_count)
190        };
191
192        // Only the count: the dock tab above already says "Scene Tree", and a
193        // second title 20px below it was the same word twice.
194        paint_text_size(
195            ui,
196            [tab_x, tab_y + 5.0],
197            &badge,
198            theme.font_size_caption,
199            theme.text_muted,
200        );
201        let _ = icons_left;
202
203        // ── Add entity (right) ────────────────────────
204        // Inset 12px from the right edge so we don't compete with the dock
205        // splitter's grab band.
206        let add_rect = [px + pw - 34.0, py + 6.0, 22.0, 22.0];
207        let add_int = ui.interact_rect("h-act-plus", add_rect);
208        if add_int.hovered {
209            ui.paint_rect_filled(
210                [add_rect[0], add_rect[1]],
211                [add_rect[2], add_rect[3]],
212                theme.surface_active,
213                4.0,
214            );
215        }
216        paint_icon(
217            ui,
218            [add_rect[0] + 5.0, add_rect[1] + 5.0],
219            Icon::Plus,
220            13.0,
221            if add_int.hovered {
222                theme.text
223            } else {
224                theme.text_dim
225            },
226        );
227        // Click spawns an empty entity; right-click offers the same list the
228        // panel's background menu does, so the button is a shortcut rather than
229        // a second, divergent way to create things.
230        ui.context_menu_last(&mut |menu| {
231            for kind in ["Empty", "Cube", "Sphere", "Plane", "Light", "Camera"] {
232                if menu.button(kind) {
233                    spawn_from_menu.set(Some(kind.to_owned()));
234                    menu.close_menu();
235                }
236            }
237        });
238        if add_int.clicked {
239            spawn_from_menu.set(Some("Empty".to_owned()));
240        }
241        if let Some(kind) = spawn_from_menu.take() {
242            state_guard.pending_spawn = Some(kind);
243        }
244
245        // ── Search toolbar ────────────────────────────
246        let toolbar_y = py + HEADER_HEIGHT;
247        let search_x = px + 8.0;
248        let search_w = pw - 16.0;
249        let search_h = 24.0;
250        ui.paint_rect_filled(
251            [search_x, toolbar_y + 4.0],
252            [search_w, search_h],
253            theme.background,
254            theme.radius_sm,
255        );
256        ui.paint_rect_stroke(
257            [search_x, toolbar_y + 4.0],
258            [search_w, search_h],
259            with_alpha(theme.separator, 0.55),
260            theme.radius_sm,
261            1.0,
262        );
263        paint_icon(
264            ui,
265            [search_x + 8.0, toolbar_y + 9.0],
266            Icon::Search,
267            12.0,
268            theme.text_muted,
269        );
270        // Optional "n / n" count on the right edge of the search pill —
271        // only painted when there's room without overlapping the input.
272        let count_w = if search_w >= 200.0 {
273            let count_text = if filter_active {
274                format!("{} / {}", visible_count, total_count)
275            } else {
276                format!("{}", total_count)
277            };
278            ui.paint_text_styled(
279                [search_x + search_w - 8.0, toolbar_y + 11.0],
280                &count_text,
281                10.0,
282                theme.text_muted,
283                FontFamilyHint::Monospace,
284                TextAlign::Right,
285            );
286            56.0
287        } else {
288            0.0
289        };
290
291        // Real text input — bound directly to `state.search_filter`.
292        let input_w = (search_w - 32.0 - count_w).max(40.0);
293        let search_filter_ref = &mut state_guard.search_filter;
294        ui.region_at(
295            "hierarchy-search",
296            [search_x + 24.0, toolbar_y + 6.0, input_w, 20.0],
297            &mut |ui_inner| {
298                ui_inner.text_edit_singleline(search_filter_ref);
299            },
300        );
301
302        // ── Section header ────────────────────────────
303        let section_y = toolbar_y + TOOLBAR_HEIGHT + 4.0;
304        ui.paint_text_styled(
305            [px + 14.0, section_y],
306            "ACTIVE SCENE",
307            10.0,
308            theme.text_muted,
309            FontFamilyHint::Proportional,
310            TextAlign::Left,
311        );
312        paint_hairline_h(
313            ui,
314            px + 102.0,
315            section_y + 6.0,
316            pw - 110.0,
317            with_alpha(theme.separator, 0.55),
318        );
319
320        // ── Rows ──────────────────────────────────────
321        let selected = state_guard.selection.clone();
322        let hidden = state_guard.hidden_entities.clone();
323        let asset_epoch = state_guard.asset_epoch;
324        let renaming = state_guard.renaming_entity;
325        let rename_rect: std::cell::Cell<Option<[f32; 4]>> = std::cell::Cell::new(None);
326        let pending: std::cell::Cell<Option<EditorAction>> = std::cell::Cell::new(None);
327
328        // F2 starts a rename on the single selected entity, matching the
329        // convention the context menu already advertises.
330        if ui.key_pressed(khora_sdk::KeyCode::F2) {
331            if let Some(entity) = state_guard.single_selected() {
332                let name = find_node_name(&state_guard.scene_roots, entity).unwrap_or_default();
333                state_guard.renaming_entity = Some(entity);
334                state_guard.rename_buffer = name;
335            }
336        }
337
338        // Rows live between the section header and the bottom of the panel.
339        let rows_top = section_y + 18.0;
340        let rows_area = [
341            px,
342            rows_top,
343            pw,
344            (panel_rect[1] + panel_rect[3] - rows_top).max(0.0),
345        ];
346        let content_h = count_visible_nodes(&filtered_roots, &self.collapsed) as f32 * ROW_HEIGHT;
347        self.scroll.update(ui, rows_area, content_h);
348        ui.push_clip_rect(rows_area);
349
350        let mut row_y = rows_top - self.scroll.offset();
351        for node in &filtered_roots {
352            row_y = render_node(
353                ui,
354                node,
355                0,
356                px,
357                pw,
358                row_y,
359                &selected,
360                &hidden,
361                &theme,
362                &pending,
363                asset_epoch,
364                &self.collapsed,
365                renaming,
366                &rename_rect,
367            );
368        }
369        // The rename field, drawn over the row that asked for it. Inside the
370        // clip so a renamed row scrolled out of view takes its field with it.
371        if let (Some(entity), Some(rect)) = (renaming, rename_rect.get()) {
372            let take_focus = !self.rename_focused;
373            self.rename_focused = true;
374            let event = ui.inline_text_field(
375                rect,
376                "hier-rename",
377                &mut state_guard.rename_buffer,
378                take_focus,
379            );
380            match event {
381                InlineEditEvent::Committed => {
382                    let new_name = state_guard.rename_buffer.trim().to_owned();
383                    if !new_name.is_empty() {
384                        state_guard.push_edit(PropertyEdit::SetName(entity, new_name));
385                    }
386                    state_guard.renaming_entity = None;
387                    self.rename_focused = false;
388                }
389                InlineEditEvent::Cancelled => {
390                    state_guard.renaming_entity = None;
391                    self.rename_focused = false;
392                }
393                _ => {}
394            }
395        } else if renaming.is_none() {
396            self.rename_focused = false;
397        }
398
399        ui.pop_clip_rect();
400        khora_tool_ui::widgets::scrollbar(
401            ui,
402            &theme,
403            rows_area,
404            content_h,
405            &mut self.scroll,
406            "hierarchy-scroll",
407        );
408
409        // Below this point `row_y` is a scrolled coordinate; the panel-wide
410        // right-click area must sit under the *visible* rows, not the virtual
411        // ones, or it would swallow clicks meant for the list.
412        let row_y = row_y.max(rows_top).min(panel_rect[1] + panel_rect[3]);
413
414        // ── Panel-wide right-click area ───────────────
415        // The remaining empty space below the last row is its own hit
416        // target with an "Add …" context menu — letting the user spawn
417        // new entities without having to right-click an existing row.
418        // It's also a drop target: dragging an entity here unparents it.
419        let panel_bottom = panel_rect[1] + panel_rect[3];
420        let empty_h = (panel_bottom - row_y).max(0.0);
421        if empty_h > 4.0 {
422            let _empty_int = ui.interact_rect("scene-tree-empty", [px, row_y, pw, empty_h]);
423            if let Some(packed) = ui.dnd_take_drop_payload() {
424                // Entity drags unparent to root; asset-tile drags instantiate
425                // into the scene (both share this drop channel).
426                if payload_is_entity(packed) {
427                    pending.set(Some(EditorAction::Reparent {
428                        child: unpack_entity(packed),
429                        new_parent: None,
430                    }));
431                } else if let Some(idx) =
432                    crate::panels::asset_browser::unpack_asset_drag(packed, asset_epoch)
433                {
434                    pending.set(Some(EditorAction::DropAsset {
435                        idx: idx as usize,
436                        target: None,
437                    }));
438                }
439            }
440            ui.context_menu_last(&mut |menu| {
441                menu.menu_button("Add", &mut |sub| {
442                    if sub.button("Empty") {
443                        pending.set(Some(EditorAction::Spawn("Empty".to_owned())));
444                        sub.close_menu();
445                    }
446                    if sub.button("Cube") {
447                        pending.set(Some(EditorAction::Spawn("Cube".to_owned())));
448                        sub.close_menu();
449                    }
450                    if sub.button("Sphere") {
451                        pending.set(Some(EditorAction::Spawn("Sphere".to_owned())));
452                        sub.close_menu();
453                    }
454                    if sub.button("Plane") {
455                        pending.set(Some(EditorAction::Spawn("Plane".to_owned())));
456                        sub.close_menu();
457                    }
458                    sub.separator();
459                    if sub.button("Camera") {
460                        pending.set(Some(EditorAction::Spawn("Camera".to_owned())));
461                        sub.close_menu();
462                    }
463                    if sub.button("Light") {
464                        pending.set(Some(EditorAction::Spawn("Light".to_owned())));
465                        sub.close_menu();
466                    }
467                });
468            });
469        }
470
471        if let Some(action) = pending.into_inner() {
472            match action {
473                EditorAction::Select(eid) => {
474                    if state_guard.ctrl_held {
475                        state_guard.toggle_select(eid);
476                    } else {
477                        state_guard.select(eid);
478                    }
479                }
480                EditorAction::ToggleCollapse(eid) => {
481                    if !self.collapsed.remove(&eid) {
482                        self.collapsed.insert(eid);
483                    }
484                }
485                EditorAction::Rename(eid) => {
486                    // Seed with the current name so the field opens on it —
487                    // renaming usually means editing, not retyping.
488                    let current = find_node_name(&state_guard.scene_roots, eid).unwrap_or_default();
489                    state_guard.renaming_entity = Some(eid);
490                    state_guard.rename_buffer = current;
491                    self.rename_focused = false;
492                }
493                EditorAction::Duplicate(eid) => {
494                    state_guard.pending_duplicate = Some(eid);
495                }
496                EditorAction::Delete(eid) => {
497                    state_guard.pending_delete = Some(eid);
498                }
499                EditorAction::Spawn(kind) => {
500                    state_guard.pending_spawn = Some(kind);
501                }
502                EditorAction::Reparent { child, new_parent } => {
503                    if Some(child) != new_parent {
504                        state_guard.pending_reparent = Some((child, new_parent));
505                    }
506                }
507                EditorAction::SaveAsPrefab(eid) => {
508                    state_guard.pending_save_as_prefab = Some(eid);
509                }
510                EditorAction::SaveAsMaterial(eid, name) => {
511                    state_guard.pending_save_as_material = Some((eid, name));
512                }
513                EditorAction::DropAsset { idx, target } => {
514                    dispatch_asset_drop(&mut state_guard, idx, target);
515                }
516            }
517        }
518    }
519}
520
521/// Routes an asset dropped onto the hierarchy to the right `pending_*` action,
522/// mirroring the viewport's drop dispatch. Meshes spawn at the world origin
523/// (a tree has no 3D drop point), prefabs instantiate, scenes load, and a
524/// texture/material assigns to the `target` row (or the selection).
525fn dispatch_asset_drop(
526    state: &mut EditorState,
527    idx: usize,
528    target: Option<khora_sdk::prelude::ecs::EntityId>,
529) {
530    let Some(entry) = state.asset_entries.get(idx).cloned() else {
531        return;
532    };
533    let rel = entry.source_path.clone();
534    match entry.asset_type.as_str() {
535        "mesh" => {
536            // Dropped on a row → parent the new entity under it (Unity/Godot
537            // convention); dropped on empty space → spawn at scene root.
538            state.pending_spawn_mesh_asset = Some((rel, [0.0, 0.0, 0.0], target));
539            log::info!(
540                "Hierarchy: mesh '{}' dropped — spawning{}",
541                entry.name,
542                if target.is_some() {
543                    " as child"
544                } else {
545                    " at root"
546                }
547            );
548        }
549        "prefab" => {
550            state.pending_prefab_spawn = Some((rel, target));
551            log::info!(
552                "Hierarchy: prefab '{}' dropped — instantiating{}",
553                entry.name,
554                if target.is_some() {
555                    " as child"
556                } else {
557                    " at root"
558                }
559            );
560        }
561        "scene" => {
562            if let Some(pf) = state.project_folder.clone() {
563                let abs = std::path::Path::new(&pf)
564                    .join("assets")
565                    .join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
566                state.pending_scene_load = Some(abs.to_string_lossy().to_string());
567                log::info!("Hierarchy: scene '{}' dropped — loading", entry.name);
568            } else {
569                log::warn!("Hierarchy: cannot load scene '{}' — no project folder", rel);
570            }
571        }
572        "texture" | "material" => match target.or_else(|| state.selection.iter().copied().next()) {
573            Some(entity) => {
574                state.pending_assign_texture = Some((rel, entity));
575                log::info!("Hierarchy: '{}' dropped — assigning to entity", entry.name);
576            }
577            None => log::warn!("Hierarchy: drop '{}' on an entity to assign it", entry.name),
578        },
579        other => log::info!("Hierarchy: asset type '{other}' is not droppable here"),
580    }
581}
582
583#[allow(clippy::too_many_arguments)]
584fn render_node(
585    ui: &mut dyn UiBuilder,
586    node: &SceneNode,
587    depth: u32,
588    px: f32,
589    pw: f32,
590    y: f32,
591    selection: &std::collections::HashSet<khora_sdk::prelude::ecs::EntityId>,
592    hidden: &std::collections::HashSet<khora_sdk::prelude::ecs::EntityId>,
593    theme: &UiTheme,
594    pending: &std::cell::Cell<Option<EditorAction>>,
595    // Current `EditorState::asset_epoch` — rejects an asset drag whose index
596    // was read before a rescan.
597    asset_epoch: u64,
598    collapsed: &std::collections::HashSet<khora_sdk::prelude::ecs::EntityId>,
599    // The entity being renamed, and where its field should go once found.
600    renaming: Option<khora_sdk::prelude::ecs::EntityId>,
601    rename_rect: &std::cell::Cell<Option<[f32; 4]>>,
602) -> f32 {
603    let row_x = px + 4.0;
604    let row_w = pw - 8.0;
605    let is_selected = selection.contains(&node.entity);
606    let is_hidden = hidden.contains(&node.entity);
607
608    // Leave the last 10px of the row unclickable so the dock splitter's grab
609    // band stays reachable — a row that swallows the edge makes the panel feel
610    // unresizable.
611    let row_click_w = (row_w - 10.0).max(0.0);
612
613    let interaction = ui.interact_rect(
614        &format!("hier-row-{}", node.entity.index),
615        [row_x, y, row_click_w, ROW_HEIGHT],
616    );
617
618    // Drag-and-drop wiring — both source and target attach to the SAME
619    // interact_rect response above, so a single hit-target serves clicks,
620    // drags, and drops without stealing each other's pointer events.
621    // Payload is the row's `EntityId` packed into u64 (high 32 = generation,
622    // low 32 = index) so reparent addresses the exact live entity, not a
623    // stale slot. Cycle prevention happens in `GameWorld::set_parent`.
624    ui.dnd_attach_drag_payload(pack_entity(node.entity));
625    if let Some(packed) = ui.dnd_take_drop_payload() {
626        // An entity drag reparents under this row; an asset-tile drag
627        // instantiates into the scene (texture/material assigns to this row).
628        if payload_is_entity(packed) {
629            let dropped = unpack_entity(packed);
630            if dropped != node.entity {
631                pending.set(Some(EditorAction::Reparent {
632                    child: dropped,
633                    new_parent: Some(node.entity),
634                }));
635            }
636        } else if let Some(idx) =
637            crate::panels::asset_browser::unpack_asset_drag(packed, asset_epoch)
638        {
639            pending.set(Some(EditorAction::DropAsset {
640                idx: idx as usize,
641                target: Some(node.entity),
642            }));
643        }
644    }
645
646    // Selection is **gold** — the same mark the asset tiles, the palette and
647    // the spine use. Silver is the brand colour and says "Khora"; gold says
648    // "this is the thing you are acting on", and it has to mean only that for
649    // the eye to find it instantly.
650    if is_selected {
651        ui.paint_rect_filled(
652            [row_x, y],
653            [row_w, ROW_HEIGHT],
654            theme.surface_active,
655            theme.radius_sm,
656        );
657        khora_tool_ui::widgets::paint::selection_bar(
658            ui,
659            [row_x, y, row_w, ROW_HEIGHT],
660            theme.accent_c,
661        );
662    } else if interaction.hovered {
663        ui.paint_rect_filled(
664            [row_x, y],
665            [row_w, ROW_HEIGHT],
666            with_alpha(theme.surface_elevated, 0.6),
667            theme.radius_sm,
668        );
669    }
670
671    if interaction.clicked {
672        pending.set(Some(EditorAction::Select(node.entity)));
673    }
674
675    // Right-click context menu on the row. We attach it to the same
676    // interaction so right-clicking anywhere on the row (excluding the
677    // eye target) opens the menu. Selecting the row first means actions
678    // like Duplicate / Delete operate on the right entity even if it
679    // wasn't already selected.
680    let entity = node.entity;
681    let node_name = node.name.clone();
682    ui.context_menu_last(&mut |menu| {
683        if menu.button("Rename") {
684            pending.set(Some(EditorAction::Rename(entity)));
685            menu.close_menu();
686        }
687        if menu.button("Duplicate") {
688            pending.set(Some(EditorAction::Duplicate(entity)));
689            menu.close_menu();
690        }
691        if menu.button("Save as Prefab…") {
692            pending.set(Some(EditorAction::SaveAsPrefab(entity)));
693            menu.close_menu();
694        }
695        if menu.button("Save Material as .kmat") {
696            pending.set(Some(EditorAction::SaveAsMaterial(
697                entity,
698                node_name.clone(),
699            )));
700            menu.close_menu();
701        }
702        menu.separator();
703        if menu.button("Delete") {
704            pending.set(Some(EditorAction::Delete(entity)));
705            menu.close_menu();
706        }
707    });
708
709    // Indent
710    let indent_px = ROW_PAD_X + depth as f32 * 14.0;
711    let mut cx = row_x + indent_px;
712
713    // Chevron — the fold control, and a hit target of its own so clicking it
714    // folds without also re-selecting the row.
715    if !node.children.is_empty() {
716        let is_collapsed = collapsed.contains(&node.entity);
717        let chev_rect = [cx - 3.0, y + 4.0, 17.0, 17.0];
718        let chev = ui.interact_rect(&format!("st-chev-{}", node.entity.index), chev_rect);
719        if chev.clicked {
720            pending.set(Some(EditorAction::ToggleCollapse(node.entity)));
721        }
722        let glyph = if is_collapsed {
723            Icon::ChevronRight
724        } else {
725            Icon::ChevronDown
726        };
727        let colour = if chev.hovered {
728            theme.text
729        } else {
730            theme.text_muted
731        };
732        paint_icon(ui, [cx, y + 7.0], glyph, 11.0, colour);
733    }
734    cx += 14.0;
735
736    // Icon
737    let base_icon_color = if is_selected {
738        theme.accent_c
739    } else {
740        theme.text_dim
741    };
742    let icon_color = if is_hidden {
743        with_alpha(base_icon_color, 0.4)
744    } else {
745        base_icon_color
746    };
747    let icon = entity_icon(node.icon);
748    paint_icon(ui, [cx, y + 6.0], icon, 13.0, icon_color);
749    cx += 18.0;
750
751    // Tag indicator — small glyph next to the type icon when the entity
752    // carries any `Tag` component entries. Tooltip would show the actual
753    // tag set; for now the glyph alone tells the user "this entity has
754    // tags, look at the inspector for details".
755    if node.tag_count > 0 {
756        paint_icon(ui, [cx, y + 6.0], Icon::Tag, 12.0, icon_color);
757        cx += 16.0;
758    }
759
760    // Label
761    let base_label_color = if is_selected {
762        theme.text
763    } else {
764        theme.text_dim
765    };
766    let label_color = if is_hidden {
767        with_alpha(base_label_color, 0.45)
768    } else {
769        base_label_color
770    };
771    // While a row is being renamed its label is replaced by a field — drawn by
772    // the caller after the loop, so the recursion doesn't have to carry a
773    // `&mut String` down every level. The row just reports where it goes.
774    if renaming == Some(node.entity) {
775        let field_w = (row_x + row_click_w - cx - 6.0).max(40.0);
776        rename_rect.set(Some([cx - 2.0, y + 3.0, field_w, ROW_HEIGHT - 6.0]));
777    } else {
778        paint_text_size(ui, [cx, y + 7.0], &node.name, 12.0, label_color);
779    }
780
781    // No visibility eye. It used to dim the row and nothing else — the object
782    // kept rendering — because `pending_visibility_toggle` was never consumed.
783    //
784    // Hiding an object belongs to a component activation model (deactivate an
785    // entity's render components), which every consuming `Flow` would have to
786    // honour or the flag is decorative all over again. That is an ECS feature,
787    // not an editor one; until it exists, no eye is better than a fake one.
788
789    let mut next_y = y + ROW_HEIGHT;
790    if collapsed.contains(&node.entity) {
791        return next_y;
792    }
793    for child in &node.children {
794        next_y = render_node(
795            ui,
796            child,
797            depth + 1,
798            px,
799            pw,
800            next_y,
801            selection,
802            hidden,
803            theme,
804            pending,
805            asset_epoch,
806            collapsed,
807            renaming,
808            rename_rect,
809        );
810    }
811    next_y
812}
813
814/// Counts the rows a forest actually shows, stopping at folded nodes.
815///
816/// Used for the scroll extent: counting the whole tree would let the view
817/// scroll past the end of a mostly-folded hierarchy.
818fn count_visible_nodes(
819    nodes: &[SceneNode],
820    collapsed: &std::collections::HashSet<khora_sdk::prelude::ecs::EntityId>,
821) -> usize {
822    nodes
823        .iter()
824        .map(|n| {
825            1 + if collapsed.contains(&n.entity) {
826                0
827            } else {
828                count_visible_nodes(&n.children, collapsed)
829            }
830        })
831        .sum()
832}
833
834/// Packs an `EntityId` into a `u64` for drag-and-drop payloads. Layout:
835/// high 32 = generation, low 32 = index. Also used by the asset
836/// browser's drop handler to ingest entity drags from the scene tree
837/// (drop = create prefab in the current folder).
838pub(crate) fn pack_entity(e: khora_sdk::prelude::ecs::EntityId) -> u64 {
839    ((e.generation as u64) << 32) | (e.index as u64)
840}
841
842/// Inverse of [`pack_entity`].
843pub(crate) fn unpack_entity(payload: u64) -> khora_sdk::prelude::ecs::EntityId {
844    khora_sdk::prelude::ecs::EntityId {
845        index: payload as u32,
846        generation: (payload >> 32) as u32,
847    }
848}
849
850/// `true` when `payload` is *not* one of the asset-browser's
851/// dedicated drag tags (the type-agnostic asset-tile tag). Lets receivers
852/// disambiguate scene-tree entity payloads from asset-tile payloads on
853/// the same `dnd_take_drop_payload` channel.
854///
855/// The check is conservative: any payload whose top 32 bits don't
856/// match a known tag is assumed to be a packed `EntityId`. Entity
857/// generations are tiny u32s (start at 1) so this can't collide with
858/// the ASCII-encoded tag constants in
859/// [`crate::panels::asset_browser`].
860pub(crate) fn payload_is_entity(payload: u64) -> bool {
861    !crate::panels::asset_browser::is_asset_drag(payload)
862}
863
864enum EditorAction {
865    Select(khora_sdk::prelude::ecs::EntityId),
866    /// Fold or unfold this node's subtree.
867    ToggleCollapse(khora_sdk::prelude::ecs::EntityId),
868    Rename(khora_sdk::prelude::ecs::EntityId),
869    Duplicate(khora_sdk::prelude::ecs::EntityId),
870    Delete(khora_sdk::prelude::ecs::EntityId),
871    Spawn(String),
872    /// Reparent `child` under `new_parent`, or detach it (root) when
873    /// `new_parent` is `None`. Emitted by the scene tree drag-and-drop
874    /// handler.
875    Reparent {
876        child: khora_sdk::prelude::ecs::EntityId,
877        new_parent: Option<khora_sdk::prelude::ecs::EntityId>,
878    },
879    /// Save the entity's subtree (root + descendants) as a `.kprefab`
880    /// asset. The path is picked through `rfd::FileDialog` in the
881    /// command dispatcher.
882    SaveAsPrefab(khora_sdk::prelude::ecs::EntityId),
883    /// Save the entity's inline material to a `.kmat` asset and convert
884    /// the entity to reference it. Carries the entity plus the chosen
885    /// material name (file stem). No-op in the dispatcher when the entity
886    /// has no inline material.
887    SaveAsMaterial(khora_sdk::prelude::ecs::EntityId, String),
888    /// An asset tile dropped onto the hierarchy: `idx` is the index into
889    /// `EditorState::asset_entries`, `target` the entity row it landed on (if
890    /// any). Dispatched by asset type — mesh/prefab/scene instantiate into the
891    /// scene, texture/material assign to `target`.
892    DropAsset {
893        idx: usize,
894        target: Option<khora_sdk::prelude::ecs::EntityId>,
895    },
896}