1use 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
30fn 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
53fn 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 scroll: khora_tool_ui::widgets::ScrollState,
71 collapsed: std::collections::HashSet<khora_sdk::prelude::ecs::EntityId>,
77 rename_focused: bool,
80}
81
82fn 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 paint_panel_header(ui, panel_rect, HEADER_HEIGHT, &theme);
139
140 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let row_y = row_y.max(rows_top).min(panel_rect[1] + panel_rect[3]);
413
414 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 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 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
521fn 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 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 asset_epoch: u64,
598 collapsed: &std::collections::HashSet<khora_sdk::prelude::ecs::EntityId>,
599 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 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 ui.dnd_attach_drag_payload(pack_entity(node.entity));
625 if let Some(packed) = ui.dnd_take_drop_payload() {
626 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 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 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 let indent_px = ROW_PAD_X + depth as f32 * 14.0;
711 let mut cx = row_x + indent_px;
712
713 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 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 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 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 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 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
814fn 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
834pub(crate) fn pack_entity(e: khora_sdk::prelude::ecs::EntityId) -> u64 {
839 ((e.generation as u64) << 32) | (e.index as u64)
840}
841
842pub(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
850pub(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 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 {
876 child: khora_sdk::prelude::ecs::EntityId,
877 new_parent: Option<khora_sdk::prelude::ecs::EntityId>,
878 },
879 SaveAsPrefab(khora_sdk::prelude::ecs::EntityId),
883 SaveAsMaterial(khora_sdk::prelude::ecs::EntityId, String),
888 DropAsset {
893 idx: usize,
894 target: Option<khora_sdk::prelude::ecs::EntityId>,
895 },
896}