1pub mod handlers;
35
36use std::collections::BTreeMap;
37use std::sync::{Arc, Mutex};
38
39use khora_sdk::editor_ui::*;
40
41use crate::widgets::chrome::paint_panel_header;
42use crate::widgets::paint::{paint_icon, paint_text_size, with_alpha};
43use crate::widgets::tile::{paint_asset_tile, AssetTileKind};
44
45use handlers::{handler_for, tile_kind_for, ActivationKind};
46
47pub(crate) const ASSET_DRAG_TAG: u64 = 0x4B48_5046_0000_0000; fn entity_display_name(
60 roots: &[khora_sdk::editor_ui::SceneNode],
61 entity: khora_sdk::prelude::ecs::EntityId,
62) -> Option<String> {
63 fn walk(
64 nodes: &[khora_sdk::editor_ui::SceneNode],
65 target: khora_sdk::prelude::ecs::EntityId,
66 ) -> Option<String> {
67 for node in nodes {
68 if node.entity == target {
69 return Some(node.name.clone());
70 }
71 if let Some(found) = walk(&node.children, target) {
72 return Some(found);
73 }
74 }
75 None
76 }
77 walk(roots, entity)
78}
79
80fn sanitize_for_filename(name: &str) -> String {
85 let mut out = String::with_capacity(name.len());
86 let mut last_was_replacement = false;
87 for ch in name.chars() {
88 let safe = ch.is_alphanumeric() || matches!(ch, '_' | '-' | '.' | ' ');
89 if safe {
90 out.push(ch);
91 last_was_replacement = false;
92 } else if !last_was_replacement {
93 out.push('_');
94 last_was_replacement = true;
95 }
96 }
97 let trimmed = out.trim_matches(&[' ', '.', '_'][..]).to_string();
98 if trimmed.is_empty() {
99 "prefab".to_string()
100 } else {
101 trimmed
102 }
103}
104
105const DRAG_EPOCH_BITS: u32 = 8;
109const DRAG_INDEX_MASK: u64 = (1 << (32 - DRAG_EPOCH_BITS)) - 1;
110
111pub(crate) fn pack_asset_drag(index: u32, epoch: u64) -> u64 {
118 let stamp = (epoch & ((1 << DRAG_EPOCH_BITS) - 1)) << (32 - DRAG_EPOCH_BITS);
119 ASSET_DRAG_TAG | stamp | (index as u64 & DRAG_INDEX_MASK)
120}
121
122pub(crate) fn is_asset_drag(payload: u64) -> bool {
129 payload & 0xFFFF_FFFF_0000_0000 == ASSET_DRAG_TAG
130}
131
132pub(crate) fn unpack_asset_drag(payload: u64, current_epoch: u64) -> Option<u32> {
135 if !is_asset_drag(payload) {
136 return None;
137 }
138 let stamp = (payload >> (32 - DRAG_EPOCH_BITS)) & ((1 << DRAG_EPOCH_BITS) - 1);
139 if stamp != current_epoch & ((1 << DRAG_EPOCH_BITS) - 1) {
140 log::debug!("Asset drop ignored: the asset list changed during the drag");
141 return None;
142 }
143 Some((payload & DRAG_INDEX_MASK) as u32)
144}
145
146fn ensure_extension(new_name: &str, old_name: &str) -> String {
150 if new_name.contains('.') {
151 return new_name.to_string();
152 }
153 match old_name.rsplit_once('.') {
154 Some((_, ext)) if !ext.is_empty() => format!("{new_name}.{ext}"),
155 _ => new_name.to_string(),
156 }
157}
158
159enum FolderAction {
162 NewFolder(String),
164 Delete(String),
166 Reveal(String),
168 StartRename { path: String, name: String },
170 Move { idx: usize, dest: String },
172}
173
174const HEADER_HEIGHT: f32 = 34.0;
175const TOOLBAR_HEIGHT: f32 = 32.0;
176const SIDEBAR_WIDTH: f32 = 220.0;
177const TILE_SIZE: f32 = 96.0;
178const TILE_GAP: f32 = 10.0;
179const SIDEBAR_ROW_H: f32 = 22.0;
180const TREE_INDENT: f32 = 14.0;
181
182#[derive(Debug, Clone)]
183struct FlatAsset {
184 name: String,
186 rel_path: String,
188 folder: String,
191 asset_type: AssetTileKind,
192 type_name: String,
193}
194
195#[derive(Debug, Default)]
197struct FolderNode {
198 full_path: String,
200 name: String,
202 children: BTreeMap<String, FolderNode>,
204 asset_count: usize,
206}
207
208pub struct AssetBrowserPanel {
209 state: Arc<Mutex<EditorState>>,
210 theme: UiTheme,
211 search_filter: String,
212 flat: Vec<FlatAsset>,
213 asset_dirs: Vec<String>,
217 last_epoch: Option<u64>,
221 selected_filter: Option<AssetTileKind>,
222 selected_index: Option<usize>,
223 renaming_index: Option<usize>,
225 rename_buffer: String,
227 renaming_folder: Option<String>,
229 folder_rename_buffer: String,
231 rename_focus_pending: bool,
234 current_folder: Option<String>,
237 expanded_folders: std::collections::HashMap<String, bool>,
240 confirm_delete: Option<(String, bool)>,
246 grid_scroll: khora_tool_ui::widgets::ScrollState,
248}
249
250impl AssetBrowserPanel {
251 pub fn new(state: Arc<Mutex<EditorState>>, theme: UiTheme) -> Self {
252 Self {
253 state,
254 theme,
255 search_filter: String::new(),
256 flat: Vec::new(),
257 asset_dirs: Vec::new(),
258 last_epoch: None,
259 selected_filter: None,
260 selected_index: None,
261 renaming_index: None,
262 rename_buffer: String::new(),
263 renaming_folder: None,
264 folder_rename_buffer: String::new(),
265 rename_focus_pending: false,
266 current_folder: None,
267 expanded_folders: std::collections::HashMap::new(),
268 confirm_delete: None,
269 grid_scroll: khora_tool_ui::widgets::ScrollState::default(),
270 }
271 }
272
273 fn render_delete_confirmation(
280 &mut self,
281 ui: &mut dyn UiBuilder,
282 panel_rect: [f32; 4],
283 theme: &UiTheme,
284 ) {
285 let Some((rel, is_folder)) = self.confirm_delete.clone() else {
286 return;
287 };
288 let name = rel.rsplit('/').next().unwrap_or(&rel).to_owned();
289 let title = if is_folder {
290 format!("Delete folder “{name}”?")
291 } else {
292 format!("Delete “{name}”?")
293 };
294 let body = if is_folder {
295 "The folder and everything inside it go to the recycle bin."
296 } else {
297 "The file goes to the recycle bin."
298 };
299
300 match khora_tool_ui::widgets::confirm_modal(
301 ui,
302 theme,
303 panel_rect,
304 "ab-delete",
305 khora_tool_ui::widgets::Confirm::danger(&title, body, "Delete"),
306 ) {
307 khora_tool_ui::widgets::ModalChoice::Confirmed => {
308 if let Ok(mut state) = self.state.lock() {
309 state.pending_delete_asset = Some(rel.clone());
310 }
311 log::info!("Asset browser: deleting '{rel}'");
312 self.confirm_delete = None;
313 }
314 khora_tool_ui::widgets::ModalChoice::Cancelled => {
315 self.confirm_delete = None;
316 }
317 khora_tool_ui::widgets::ModalChoice::Pending => {}
318 }
319 }
320
321 fn rescan_if_needed(&mut self) {
322 let (project_folder, epoch, entries, dirs) = match self.state.lock() {
323 Ok(s) => (
324 s.project_folder.clone(),
325 s.asset_epoch,
326 s.asset_entries
327 .iter()
328 .map(|e| (e.name.clone(), e.asset_type.clone(), e.source_path.clone()))
329 .collect::<Vec<_>>(),
330 s.asset_dirs.clone(),
331 ),
332 Err(_) => return,
333 };
334
335 if project_folder.is_none() {
336 if self.last_epoch.is_some() || !self.flat.is_empty() {
337 self.flat.clear();
338 self.asset_dirs.clear();
339 self.last_epoch = None;
340 self.selected_index = None;
341 self.renaming_index = None;
342 self.renaming_folder = None;
343 self.current_folder = None;
344 }
345 return;
346 }
347
348 if self.last_epoch == Some(epoch) {
351 return;
352 }
353 self.last_epoch = Some(epoch);
354 self.asset_dirs = dirs;
355
356 if let Some(idx) = self.selected_index {
357 if idx >= entries.len() {
358 self.selected_index = None;
359 }
360 }
361 self.renaming_index = None;
363
364 self.flat = entries
365 .into_iter()
366 .map(|(name, asset_type_name, rel_path)| {
367 let folder = rel_path
368 .rfind('/')
369 .map(|p| rel_path[..p].to_string())
370 .unwrap_or_default();
371 FlatAsset {
372 name,
373 rel_path,
374 folder,
375 asset_type: tile_kind_for(&asset_type_name),
376 type_name: asset_type_name,
377 }
378 })
379 .collect();
380
381 if self.current_folder.is_none() {
382 self.current_folder = Some(String::new());
383 self.expanded_folders.insert(String::new(), true);
384 }
385 }
386
387 fn build_folder_tree(&self) -> FolderNode {
388 let mut root = FolderNode {
389 full_path: String::new(),
390 name: String::new(),
391 children: BTreeMap::new(),
392 asset_count: 0,
393 };
394 for asset in &self.flat {
395 root.asset_count += 1;
396 if asset.folder.is_empty() {
397 continue;
398 }
399 let mut cursor = &mut root;
400 let mut accumulated = String::new();
401 for segment in asset.folder.split('/') {
402 if !accumulated.is_empty() {
403 accumulated.push('/');
404 }
405 accumulated.push_str(segment);
406 cursor = cursor
407 .children
408 .entry(segment.to_string())
409 .or_insert_with(|| FolderNode {
410 full_path: accumulated.clone(),
411 name: segment.to_string(),
412 children: BTreeMap::new(),
413 asset_count: 0,
414 });
415 cursor.asset_count += 1;
416 }
417 }
418 for dir in &self.asset_dirs {
422 if dir.is_empty() {
423 continue;
424 }
425 let mut cursor = &mut root;
426 let mut accumulated = String::new();
427 for segment in dir.split('/') {
428 if !accumulated.is_empty() {
429 accumulated.push('/');
430 }
431 accumulated.push_str(segment);
432 cursor = cursor
433 .children
434 .entry(segment.to_string())
435 .or_insert_with(|| FolderNode {
436 full_path: accumulated.clone(),
437 name: segment.to_string(),
438 children: BTreeMap::new(),
439 asset_count: 0,
440 });
441 }
442 }
443 root
444 }
445
446 fn absolute_rel_path(&self, rel: &str) -> Option<String> {
449 let project_folder = self
450 .state
451 .lock()
452 .ok()
453 .and_then(|s| s.project_folder.clone())?;
454 let abs = std::path::Path::new(&project_folder)
455 .join("assets")
456 .join(rel.replace('/', std::path::MAIN_SEPARATOR_STR));
457 Some(abs.to_string_lossy().to_string())
458 }
459
460 fn absolute_path_for(&self, asset: &FlatAsset) -> Option<String> {
461 self.absolute_rel_path(&asset.rel_path)
462 }
463
464 fn reveal_in_explorer(&self, rel: &str, is_dir: bool) {
467 let Some(abs) = self.absolute_rel_path(rel) else {
468 log::warn!("Asset browser: cannot reveal '{rel}' — no project folder set");
469 return;
470 };
471 let target = if is_dir {
472 std::path::PathBuf::from(&abs)
473 } else {
474 std::path::Path::new(&abs)
475 .parent()
476 .map(|p| p.to_path_buf())
477 .unwrap_or_else(|| std::path::PathBuf::from(&abs))
478 };
479 if let Err(e) = open::that(&target) {
480 log::warn!("Asset browser: failed to reveal '{rel}': {e}");
481 }
482 }
483
484 fn reveal_project(&self) {
486 match self
487 .state
488 .lock()
489 .ok()
490 .and_then(|s| s.project_folder.clone())
491 {
492 Some(folder) => {
493 if let Err(e) = open::that(&folder) {
494 log::warn!("Asset browser: failed to reveal project folder: {e}");
495 }
496 }
497 None => log::info!("Asset browser: no project open to reveal"),
498 }
499 }
500
501 fn queue_new_folder(&self, parent: &str) {
504 let candidate = |name: &str| {
505 if parent.is_empty() {
506 name.to_string()
507 } else {
508 format!("{parent}/{name}")
509 }
510 };
511 let mut name = "New Folder".to_string();
512 let mut n = 1;
513 while self.asset_dirs.iter().any(|d| d == &candidate(&name)) {
514 n += 1;
515 name = format!("New Folder {n}");
516 }
517 let rel = candidate(&name);
518 if let Ok(mut state) = self.state.lock() {
519 state.pending_create_folder = Some(rel.clone());
520 }
521 log::info!("Asset browser: creating folder '{rel}'");
522 }
523
524 fn paint_drag_ghost(
528 &self,
529 ui: &mut dyn UiBuilder,
530 cursor: [f32; 2],
531 name: &str,
532 kind: AssetTileKind,
533 theme: &UiTheme,
534 ) {
535 let text_w = ui.measure_text(name, 11.5, FontFamilyHint::Proportional)[0];
536 let pad = 8.0;
537 let icon_w = 16.0;
538 let w = (icon_w + text_w + pad * 2.0 + 4.0).min(220.0);
539 let h = 24.0;
540 let x = cursor[0] + 14.0;
541 let y = cursor[1] + 6.0;
542 let accent = kind.accent(theme);
543 ui.overlay_rect_filled(
547 [x + 1.0, y + 2.0],
548 [w, h],
549 with_alpha(theme.background, 0.45),
550 theme.radius_md,
551 );
552 ui.overlay_rect_filled(
553 [x, y],
554 [w, h],
555 with_alpha(theme.surface, 0.98),
556 theme.radius_md,
557 );
558 ui.overlay_rect_stroke(
559 [x, y],
560 [w, h],
561 with_alpha(accent, 0.9),
562 theme.radius_md,
563 1.0,
564 );
565 ui.overlay_text(
566 [x + pad, y + 6.0],
567 kind.icon().glyph(),
568 12.0,
569 accent,
570 FontFamilyHint::Icons,
571 );
572 ui.overlay_text(
573 [x + pad + icon_w, y + 6.0],
574 name,
575 11.5,
576 theme.text,
577 FontFamilyHint::Proportional,
578 );
579 }
580
581 fn queue_save_entity_as_prefab(&self, entity: khora_sdk::prelude::ecs::EntityId) {
585 let folder = self.current_folder.clone().unwrap_or_default();
586 if let Ok(mut state) = self.state.lock() {
587 let name = entity_display_name(&state.scene_roots, entity)
588 .unwrap_or_else(|| format!("Entity_{}", entity.index));
589 let stem = sanitize_for_filename(&name);
590 let rel_path = if folder.is_empty() {
591 format!("{stem}.kprefab")
592 } else {
593 format!("{folder}/{stem}.kprefab")
594 };
595 state.pending_save_as_prefab_at = Some((entity, rel_path));
596 log::info!("Asset browser: entity '{name}' dropped — creating prefab in '{folder}'");
597 }
598 }
599
600 fn activate_asset(&self, asset: &FlatAsset) {
601 let Some(abs_path) = self.absolute_path_for(asset) else {
602 log::warn!(
603 "Asset browser: cannot activate '{}' — no project folder set",
604 asset.rel_path
605 );
606 return;
607 };
608 let activation = handler_for(&asset.type_name)
609 .map(|h| h.activate(abs_path.clone()))
610 .unwrap_or(ActivationKind::OpenExternal { abs_path });
611
612 match activation {
613 ActivationKind::LoadScene { abs_path } => {
614 if let Ok(mut state) = self.state.lock() {
615 state.pending_scene_load = Some(abs_path);
616 log::info!("Asset browser: loading scene '{}'", asset.rel_path);
617 }
618 }
619 ActivationKind::SpawnPrefab { abs_path: _ } => {
620 if let Ok(mut state) = self.state.lock() {
621 state.pending_prefab_spawn = Some((asset.rel_path.clone(), None));
622 log::info!("Asset browser: spawning prefab '{}'", asset.rel_path);
623 }
624 }
625 ActivationKind::OpenExternal { abs_path } => match open::that(&abs_path) {
626 Ok(()) => log::info!(
627 "Asset browser: opened '{}' in OS-default application",
628 asset.rel_path
629 ),
630 Err(e) => log::warn!(
631 "Asset browser: failed to open '{}' externally: {}",
632 asset.rel_path,
633 e
634 ),
635 },
636 }
637 }
638
639 fn render_folder_tree(
642 &mut self,
643 ui: &mut dyn UiBuilder,
644 origin_x: f32,
645 origin_y: f32,
646 sidebar_w: f32,
647 theme: &UiTheme,
648 ) -> f32 {
649 let root = self.build_folder_tree();
650 let mut y = origin_y;
651 let mut new_current = self.current_folder.clone();
652 let mut new_expanded: Vec<(String, bool)> = Vec::new();
653 let mut actions: Vec<FolderAction> = Vec::new();
654 let mut rename_rect: Option<[f32; 4]> = None;
658 self.render_folder_node(
659 ui,
660 &root,
661 0,
662 origin_x,
663 &mut y,
664 sidebar_w,
665 theme,
666 &mut new_current,
667 &mut new_expanded,
668 &mut actions,
669 &mut rename_rect,
670 );
671 for (path, open) in new_expanded {
672 self.expanded_folders.insert(path, open);
673 }
674 if new_current != self.current_folder {
675 self.current_folder = new_current;
676 }
677
678 for action in actions {
680 match action {
681 FolderAction::NewFolder(parent) => self.queue_new_folder(&parent),
682 FolderAction::Delete(rel) => {
683 self.confirm_delete = Some((rel, true));
684 }
685 FolderAction::Reveal(rel) => self.reveal_in_explorer(&rel, true),
686 FolderAction::StartRename { path, name } => {
687 self.renaming_folder = Some(path);
688 self.folder_rename_buffer = name;
689 self.rename_focus_pending = true;
690 }
691 FolderAction::Move { idx, dest } => {
692 if let Some(src) = self.flat.get(idx).map(|a| a.rel_path.clone()) {
693 if let Ok(mut state) = self.state.lock() {
694 state.pending_move_asset = Some((src.clone(), dest.clone()));
695 }
696 log::info!("Asset browser: moving '{src}' into '{dest}'");
697 }
698 }
699 }
700 }
701
702 if let Some(rect) = rename_rect {
704 let focus = self.rename_focus_pending;
705 let buf = &mut self.folder_rename_buffer;
706 let event = ui.inline_text_field(rect, "folder-rename", buf, focus);
707 self.rename_focus_pending = false;
708 match event {
709 InlineEditEvent::Committed => {
710 if let Some(old) = self.renaming_folder.clone() {
711 let new_name = self.folder_rename_buffer.trim().to_string();
712 if !new_name.is_empty() {
713 let parent = old
714 .rfind('/')
715 .map(|p| old[..p].to_string())
716 .unwrap_or_default();
717 let new_rel = if parent.is_empty() {
718 new_name
719 } else {
720 format!("{parent}/{new_name}")
721 };
722 if new_rel != old {
723 if let Ok(mut state) = self.state.lock() {
724 state.pending_rename_asset = Some((old, new_rel));
725 }
726 }
727 }
728 }
729 self.renaming_folder = None;
730 }
731 InlineEditEvent::Cancelled => self.renaming_folder = None,
732 _ => {}
733 }
734 }
735 y
736 }
737
738 #[allow(clippy::too_many_arguments)]
739 fn render_folder_node(
740 &self,
741 ui: &mut dyn UiBuilder,
742 node: &FolderNode,
743 depth: u32,
744 origin_x: f32,
745 y: &mut f32,
746 sidebar_w: f32,
747 theme: &UiTheme,
748 current: &mut Option<String>,
749 new_expanded: &mut Vec<(String, bool)>,
750 actions: &mut Vec<FolderAction>,
751 rename_rect: &mut Option<[f32; 4]>,
752 ) {
753 let row_x = origin_x + 4.0;
754 let row_w = sidebar_w - 8.0;
755 let label = if node.name.is_empty() {
756 "assets"
757 } else {
758 node.name.as_str()
759 };
760 let is_root = node.full_path.is_empty();
761 let is_renaming = self.renaming_folder.as_deref() == Some(node.full_path.as_str());
762 let expanded = is_root
763 || self
764 .expanded_folders
765 .get(&node.full_path)
766 .copied()
767 .unwrap_or(false);
768 let is_current = current.as_deref() == Some(node.full_path.as_str());
769 let interaction = ui.interact_rect(
770 &format!("ab-tree-{}", node.full_path),
771 [row_x, *y, row_w, SIDEBAR_ROW_H],
772 );
773
774 {
779 let full = node.full_path.clone();
780 let display = label.to_string();
781 let allow_edit = !is_root;
782 ui.context_menu_last(&mut |menu| {
783 if menu.button("New Folder") {
784 actions.push(FolderAction::NewFolder(full.clone()));
785 menu.close_menu();
786 }
787 if allow_edit && menu.button("Rename") {
788 actions.push(FolderAction::StartRename {
789 path: full.clone(),
790 name: display.clone(),
791 });
792 menu.close_menu();
793 }
794 if allow_edit && menu.button("Delete") {
795 actions.push(FolderAction::Delete(full.clone()));
796 menu.close_menu();
797 }
798 menu.separator();
799 if menu.button("Reveal in Explorer") {
800 actions.push(FolderAction::Reveal(full.clone()));
801 menu.close_menu();
802 }
803 });
804 }
805 if let Some(payload) = ui.dnd_take_drop_payload() {
806 if let Some(idx) = unpack_asset_drag(payload, self.last_epoch.unwrap_or(0)) {
807 actions.push(FolderAction::Move {
808 idx: idx as usize,
809 dest: node.full_path.clone(),
810 });
811 }
812 }
813
814 let drop_hover = ui.is_drag_active() && interaction.hovered;
818 if drop_hover {
819 ui.paint_rect_filled(
820 [row_x, *y],
821 [row_w, SIDEBAR_ROW_H],
822 with_alpha(theme.accent_a, 0.28),
823 theme.radius_sm,
824 );
825 ui.paint_rect_stroke(
826 [row_x, *y],
827 [row_w, SIDEBAR_ROW_H],
828 with_alpha(theme.accent_a, 0.9),
829 theme.radius_sm,
830 1.0,
831 );
832 } else if is_current {
833 ui.paint_rect_filled(
834 [row_x, *y],
835 [row_w, SIDEBAR_ROW_H],
836 with_alpha(theme.primary, 0.14),
837 theme.radius_sm,
838 );
839 } else if interaction.hovered {
840 ui.paint_rect_filled(
841 [row_x, *y],
842 [row_w, SIDEBAR_ROW_H],
843 with_alpha(theme.surface_active, 0.5),
844 theme.radius_sm,
845 );
846 }
847 let chev_x = row_x + 4.0 + depth as f32 * TREE_INDENT;
848 let has_children = !node.children.is_empty();
849 if has_children {
850 let chev = if expanded {
851 Icon::ChevronDown
852 } else {
853 Icon::ChevronRight
854 };
855 paint_icon(ui, [chev_x, *y + 5.0], chev, 11.0, theme.text_muted);
856 }
857 let icon_x = chev_x + 14.0;
858 paint_icon(
859 ui,
860 [icon_x, *y + 5.0],
861 Icon::Database,
862 11.0,
863 if is_current {
864 theme.primary
865 } else {
866 theme.text_dim
867 },
868 );
869 if is_renaming {
870 *rename_rect = Some([
873 icon_x + 12.0,
874 *y + 2.0,
875 row_w - (icon_x - row_x) - 24.0,
876 18.0,
877 ]);
878 } else {
879 paint_text_size(
880 ui,
881 [icon_x + 14.0, *y + 5.0],
882 label,
883 11.5,
884 if is_current {
885 theme.text
886 } else {
887 theme.text_dim
888 },
889 );
890 ui.paint_text_styled(
891 [row_x + row_w - 6.0, *y + 5.0],
892 &format!("{}", node.asset_count),
893 10.0,
894 theme.text_muted,
895 FontFamilyHint::Monospace,
896 TextAlign::Right,
897 );
898 }
899 if interaction.clicked {
900 *current = Some(node.full_path.clone());
901 if has_children {
904 new_expanded.push((node.full_path.clone(), !expanded));
905 }
906 }
907 *y += SIDEBAR_ROW_H + 1.0;
908 if expanded {
909 for child in node.children.values() {
910 self.render_folder_node(
911 ui,
912 child,
913 depth + 1,
914 origin_x,
915 y,
916 sidebar_w,
917 theme,
918 current,
919 new_expanded,
920 actions,
921 rename_rect,
922 );
923 }
924 }
925 }
926
927 fn render_breadcrumb(
930 &mut self,
931 ui: &mut dyn UiBuilder,
932 origin_x: f32,
933 origin_y: f32,
934 max_w: f32,
935 theme: &UiTheme,
936 project_folder: &str,
937 ) {
938 let project_name = std::path::Path::new(project_folder)
939 .file_name()
940 .and_then(|s| s.to_str())
941 .unwrap_or("project");
942 let folder = self.current_folder.clone().unwrap_or_default();
943 let mut segments: Vec<(String, String)> = Vec::new();
944 segments.push((project_name.to_string(), "__project".to_string()));
945 segments.push(("assets".to_string(), String::new()));
946 if !folder.is_empty() {
947 let mut accumulated = String::new();
948 for seg in folder.split('/') {
949 if !accumulated.is_empty() {
950 accumulated.push('/');
951 }
952 accumulated.push_str(seg);
953 segments.push((seg.to_string(), accumulated.clone()));
954 }
955 }
956
957 let mut x = origin_x;
958 let y = origin_y;
959 for (i, (label, target)) in segments.iter().enumerate() {
960 let active = i == segments.len() - 1;
961 let label_w = ui.measure_text(label, 11.0, FontFamilyHint::Monospace)[0] + 6.0;
962 if x + label_w > origin_x + max_w {
963 break;
964 }
965 let rect = [x, y, label_w, 18.0];
966 let salt = format!("ab-crumb-{}", i);
967 let int = ui.interact_rect(&salt, rect);
968 if int.hovered && !active {
969 ui.paint_rect_filled(
970 [x, y],
971 [label_w, 18.0],
972 theme.surface_active,
973 theme.radius_sm,
974 );
975 }
976 ui.paint_text_styled(
977 [x + 3.0, y + 2.5],
978 label,
979 11.0,
980 if active { theme.text } else { theme.text_dim },
981 FontFamilyHint::Monospace,
982 TextAlign::Left,
983 );
984 if int.clicked && target != "__project" && !active {
985 self.current_folder = Some(target.clone());
986 }
987 x += label_w;
988 if i < segments.len() - 1 {
989 khora_tool_ui::widgets::paint::icon(
990 ui,
991 [x, y + 2.0],
992 Icon::ChevronRight,
993 12.0,
994 theme.text_disabled,
995 );
996 x += 14.0;
997 }
998 }
999 }
1000}
1001
1002const SIDEBAR_CATEGORIES: &[(AssetTileKind, &str, Icon)] = &[
1003 (AssetTileKind::Unknown, "All Assets", Icon::Database),
1004 (AssetTileKind::Scene, "Scenes", Icon::Film),
1005 (AssetTileKind::Mesh, "Meshes", Icon::Cube),
1006 (AssetTileKind::Texture, "Textures", Icon::Image),
1007 (AssetTileKind::Material, "Materials", Icon::Circle),
1008 (AssetTileKind::Audio, "Audio", Icon::Music),
1009 (AssetTileKind::Shader, "Shaders", Icon::Zap),
1010 (AssetTileKind::Script, "Scripts", Icon::Code),
1011];
1012
1013impl EditorPanel for AssetBrowserPanel {
1014 fn id(&self) -> &str {
1015 "khora.editor.asset_browser"
1016 }
1017 fn title(&self) -> &str {
1018 "Assets"
1019 }
1020 fn ui(&mut self, ui: &mut dyn UiBuilder) {
1021 self.rescan_if_needed();
1022
1023 let theme = self.theme.clone();
1024 let panel_rect = ui.panel_rect();
1025 let [px, py, pw, ph] = panel_rect;
1026
1027 paint_panel_header(ui, panel_rect, HEADER_HEIGHT, &theme);
1029 let tab_y = py + (HEADER_HEIGHT - 22.0) * 0.5;
1030 let badge = format!("{}", self.flat.len());
1031 paint_text_size(
1033 ui,
1034 [px + 6.0, tab_y + 5.0],
1035 &badge,
1036 theme.font_size_caption,
1037 theme.text_muted,
1038 );
1039
1040 let mut header_delete_selected = false;
1044 let mut header_filter_pick: Option<Option<AssetTileKind>> = None;
1045 let mut header_new_folder = false;
1046 let mut header_reveal_project = false;
1047 let mut ax = px + pw - 8.0;
1048 for (icon, salt) in [
1049 (Icon::More, "ab-more"),
1050 (Icon::Trash, "ab-trash"),
1051 (Icon::Filter, "ab-filter"),
1052 ] {
1053 ax -= 22.0;
1054 let int = ui.interact_rect(salt, [ax, py + 6.0, 22.0, 22.0]);
1055 if int.hovered {
1056 ui.paint_rect_filled([ax, py + 6.0], [22.0, 22.0], theme.surface_active, 4.0);
1057 }
1058 paint_icon(ui, [ax + 5.0, py + 11.0], icon, 13.0, theme.text_dim);
1059 match salt {
1060 "ab-trash" if int.clicked => {
1061 header_delete_selected = true;
1062 }
1063 "ab-filter" => {
1064 ui.context_menu_last(&mut |menu| {
1065 for (kind, label, _icon) in SIDEBAR_CATEGORIES {
1066 if menu.button(label) {
1067 header_filter_pick = Some(if *kind == AssetTileKind::Unknown {
1068 None
1069 } else {
1070 Some(*kind)
1071 });
1072 menu.close_menu();
1073 }
1074 }
1075 });
1076 }
1077 "ab-more" => {
1078 ui.context_menu_last(&mut |menu| {
1079 if menu.button("New Folder") {
1080 header_new_folder = true;
1081 menu.close_menu();
1082 }
1083 if menu.button("Reveal Project in Explorer") {
1084 header_reveal_project = true;
1085 menu.close_menu();
1086 }
1087 });
1088 }
1089 _ => {}
1090 }
1091 }
1092 if let Some(pick) = header_filter_pick {
1093 self.selected_filter = pick;
1094 }
1095 if header_delete_selected {
1096 match self.selected_index.and_then(|i| self.flat.get(i)) {
1097 Some(asset) => {
1098 self.confirm_delete = Some((asset.rel_path.clone(), false));
1099 }
1100 None => log::info!("Asset browser: nothing selected to delete"),
1101 }
1102 }
1103 if header_new_folder {
1104 let parent = self.current_folder.clone().unwrap_or_default();
1105 self.queue_new_folder(&parent);
1106 }
1107 if header_reveal_project {
1108 self.reveal_project();
1109 }
1110
1111 let body_y = py + HEADER_HEIGHT;
1113 let body_h = ph - HEADER_HEIGHT;
1114 let sidebar_w = SIDEBAR_WIDTH;
1115
1116 ui.paint_rect_filled(
1117 [px, body_y],
1118 [sidebar_w, body_h],
1119 theme.surface_elevated,
1120 0.0,
1121 );
1122 ui.paint_line(
1123 [px + sidebar_w, body_y],
1124 [px + sidebar_w, body_y + body_h],
1125 with_alpha(theme.separator, 0.55),
1126 1.0,
1127 );
1128
1129 let mut row_y = body_y + 8.0;
1131 let row_h = 22.0;
1132 let mut new_filter = self.selected_filter;
1133 for (kind, label, icon) in SIDEBAR_CATEGORIES {
1134 let count = if *kind == AssetTileKind::Unknown {
1135 self.flat.len()
1136 } else {
1137 self.flat.iter().filter(|a| a.asset_type == *kind).count()
1138 };
1139 let active = self.selected_filter == Some(*kind)
1140 || (self.selected_filter.is_none() && *kind == AssetTileKind::Unknown);
1141 let row_x = px + 4.0;
1142 let row_w = sidebar_w - 8.0;
1143 let interaction =
1144 ui.interact_rect(&format!("ab-side-{}", label), [row_x, row_y, row_w, row_h]);
1145 if active {
1146 ui.paint_rect_filled(
1147 [row_x, row_y],
1148 [row_w, row_h],
1149 with_alpha(theme.primary, 0.14),
1150 theme.radius_sm,
1151 );
1152 } else if interaction.hovered {
1153 ui.paint_rect_filled(
1154 [row_x, row_y],
1155 [row_w, row_h],
1156 with_alpha(theme.surface_active, 0.5),
1157 theme.radius_sm,
1158 );
1159 }
1160 paint_icon(
1161 ui,
1162 [row_x + 8.0, row_y + 5.0],
1163 *icon,
1164 12.0,
1165 if active {
1166 theme.primary
1167 } else {
1168 theme.text_dim
1169 },
1170 );
1171 paint_text_size(
1172 ui,
1173 [row_x + 26.0, row_y + 5.0],
1174 label,
1175 11.5,
1176 if active { theme.text } else { theme.text_dim },
1177 );
1178 ui.paint_text_styled(
1179 [row_x + row_w - 6.0, row_y + 5.0],
1180 &format!("{}", count),
1181 10.0,
1182 theme.text_muted,
1183 FontFamilyHint::Monospace,
1184 TextAlign::Right,
1185 );
1186 if interaction.clicked {
1187 new_filter = if *kind == AssetTileKind::Unknown {
1188 None
1189 } else {
1190 Some(*kind)
1191 };
1192 }
1193 row_y += row_h + 1.0;
1194 }
1195 self.selected_filter = new_filter;
1196
1197 row_y += 8.0;
1199 paint_text_size(ui, [px + 8.0, row_y], "FOLDERS", 10.0, theme.text_muted);
1200 row_y += 16.0;
1201
1202 let _ = self.render_folder_tree(ui, px, row_y, sidebar_w, &theme);
1204
1205 let grid_x = px + sidebar_w;
1207 let grid_w = pw - sidebar_w;
1208
1209 let crumb_y = body_y;
1210 ui.paint_rect_filled(
1211 [grid_x, crumb_y],
1212 [grid_w, TOOLBAR_HEIGHT],
1213 theme.surface,
1214 0.0,
1215 );
1216 ui.paint_line(
1217 [grid_x, crumb_y + TOOLBAR_HEIGHT],
1218 [grid_x + grid_w, crumb_y + TOOLBAR_HEIGHT],
1219 with_alpha(theme.separator, 0.55),
1220 1.0,
1221 );
1222 let project_folder = self
1223 .state
1224 .lock()
1225 .ok()
1226 .and_then(|s| s.project_folder.clone())
1227 .unwrap_or_default();
1228
1229 let search_w = 200.0_f32.min(grid_w * 0.4);
1230 let search_x = grid_x + grid_w - search_w - 12.0;
1231 let search_y = crumb_y + 4.0;
1232
1233 let crumb_max_w = (search_x - grid_x - 24.0).max(0.0);
1235 self.render_breadcrumb(
1236 ui,
1237 grid_x + 12.0,
1238 crumb_y + 7.0,
1239 crumb_max_w,
1240 &theme,
1241 &project_folder,
1242 );
1243
1244 paint_icon(
1246 ui,
1247 [search_x + 4.0, search_y + 5.0],
1248 Icon::Search,
1249 12.0,
1250 theme.text_muted,
1251 );
1252
1253 let grid_inner_x = grid_x + 12.0;
1255 let grid_inner_y = crumb_y + TOOLBAR_HEIGHT + 12.0;
1256 let grid_inner_w = grid_w - 24.0;
1257 let cols = ((grid_inner_w + TILE_GAP) / (TILE_SIZE + TILE_GAP))
1258 .max(1.0)
1259 .floor() as usize;
1260
1261 let filter_text = self.search_filter.to_lowercase();
1262 let current_folder = self.current_folder.clone().unwrap_or_default();
1263 let visible: Vec<(usize, &FlatAsset)> = self
1264 .flat
1265 .iter()
1266 .enumerate()
1267 .filter(|(_, a)| match self.selected_filter {
1268 Some(k) => a.asset_type == k,
1269 None => true,
1270 })
1271 .filter(|(_, a)| {
1272 if current_folder.is_empty() {
1276 true
1277 } else {
1278 a.folder == current_folder
1279 || a.folder.starts_with(&format!("{}/", current_folder))
1280 }
1281 })
1282 .filter(|(_, a)| filter_text.is_empty() || a.name.to_lowercase().contains(&filter_text))
1283 .collect();
1284
1285 let drop_y = grid_inner_y;
1294 let drop_h = (body_y + body_h - grid_inner_y).max(0.0);
1295 let _drop_int =
1296 ui.interact_rect("ab-grid-drop", [grid_inner_x, drop_y, grid_inner_w, drop_h]);
1297 if let Some(payload) = ui.dnd_take_drop_payload() {
1298 if crate::panels::scene_tree::payload_is_entity(payload) {
1299 let entity = crate::panels::scene_tree::unpack_entity(payload);
1300 self.queue_save_entity_as_prefab(entity);
1301 }
1302 }
1303
1304 let mut bg_new_folder = false;
1308 let mut bg_reveal_current = false;
1309 ui.context_menu_last(&mut |menu| {
1310 if menu.button("New Folder") {
1311 bg_new_folder = true;
1312 menu.close_menu();
1313 }
1314 if menu.button("Reveal Current Folder") {
1315 bg_reveal_current = true;
1316 menu.close_menu();
1317 }
1318 });
1319 if bg_new_folder || bg_reveal_current {
1320 let cur = self.current_folder.clone().unwrap_or_default();
1321 if bg_new_folder {
1322 self.queue_new_folder(&cur);
1323 }
1324 if bg_reveal_current {
1325 self.reveal_in_explorer(&cur, true);
1326 }
1327 }
1328
1329 let tile_h = TILE_SIZE + 22.0;
1330 let mut to_select: Option<usize> = None;
1331 let mut to_activate: Option<usize> = None;
1332 let mut to_assign_material: Option<String> = None;
1333 let mut to_reveal: Option<usize> = None;
1334 let mut to_rename: Option<usize> = None;
1335 let mut to_duplicate: Option<String> = None;
1336 let mut to_delete: Option<String> = None;
1337 let mut dragging_ghost: Option<(String, AssetTileKind)> = None;
1340 let mut entity_drop: Option<khora_sdk::prelude::ecs::EntityId> = None;
1342 let grid_view = [grid_inner_x, grid_inner_y, grid_inner_w, drop_h];
1346 let rows_total = visible.len().div_ceil(cols.max(1)) as f32;
1347 let grid_content_h = rows_total * (tile_h + TILE_GAP);
1348 self.grid_scroll.update(ui, grid_view, grid_content_h);
1349 ui.push_clip_rect(grid_view);
1350 let grid_origin_y = grid_inner_y - self.grid_scroll.offset();
1351
1352 for (i, (orig_idx, asset)) in visible.iter().enumerate() {
1353 let col = i % cols;
1354 let row = i / cols;
1355 let tx = grid_inner_x + col as f32 * (TILE_SIZE + TILE_GAP);
1356 let ty = grid_origin_y + row as f32 * (tile_h + TILE_GAP);
1357 if ty + tile_h < grid_view[1] || ty > grid_view[1] + grid_view[3] {
1360 continue;
1361 }
1362 let selected = self.selected_index == Some(*orig_idx);
1363 let interaction = paint_asset_tile(
1364 ui,
1365 &format!("tile-{}", orig_idx),
1366 [tx, ty],
1367 [TILE_SIZE, tile_h],
1368 &asset.name,
1369 asset.asset_type,
1370 selected,
1371 &theme,
1372 );
1373 ui.dnd_attach_drag_payload(pack_asset_drag(
1379 *orig_idx as u32,
1380 self.last_epoch.unwrap_or(0),
1381 ));
1382 if ui.is_last_item_dragged() {
1383 dragging_ghost = Some((asset.name.clone(), asset.asset_type));
1384 }
1385 if let Some(payload) = ui.dnd_take_drop_payload() {
1390 if crate::panels::scene_tree::payload_is_entity(payload) {
1391 entity_drop = Some(crate::panels::scene_tree::unpack_entity(payload));
1392 }
1393 }
1394 let idx = *orig_idx;
1398 let rel = asset.rel_path.clone();
1399 let is_material = asset.type_name == "material";
1400 ui.context_menu_last(&mut |menu| {
1401 if menu.button("Open") {
1402 to_activate = Some(idx);
1403 menu.close_menu();
1404 }
1405 if menu.button("Reveal in Explorer") {
1406 to_reveal = Some(idx);
1407 menu.close_menu();
1408 }
1409 if is_material && menu.button("Assign to selected") {
1410 to_assign_material = Some(rel.clone());
1411 menu.close_menu();
1412 }
1413 menu.separator();
1414 if menu.button("Rename") {
1415 to_rename = Some(idx);
1416 menu.close_menu();
1417 }
1418 if menu.button("Duplicate") {
1419 to_duplicate = Some(rel.clone());
1420 menu.close_menu();
1421 }
1422 if menu.button("Delete") {
1423 to_delete = Some(rel.clone());
1424 menu.close_menu();
1425 }
1426 });
1427 if interaction.double_clicked {
1428 to_activate = Some(*orig_idx);
1429 } else if interaction.clicked {
1430 to_select = Some(*orig_idx);
1431 }
1432 }
1433 ui.pop_clip_rect();
1434 khora_tool_ui::widgets::scrollbar(
1435 ui,
1436 &theme,
1437 grid_view,
1438 grid_content_h,
1439 &mut self.grid_scroll,
1440 "ab-grid-scroll",
1441 );
1442
1443 if let Some((name, kind)) = dragging_ghost {
1446 if let Some(pos) = ui.pointer_position() {
1447 self.paint_drag_ghost(ui, pos, &name, kind, &theme);
1448 }
1449 }
1450 if let Some(entity) = entity_drop {
1451 self.queue_save_entity_as_prefab(entity);
1452 }
1453 if let Some(rel) = to_assign_material {
1454 if let Ok(mut state) = self.state.lock() {
1455 if state.selection.is_empty() {
1456 log::warn!(
1457 "Asset browser: assign material '{}' ignored — no entity selected",
1458 rel
1459 );
1460 } else {
1461 state.pending_assign_material = Some(rel.clone());
1462 log::info!("Asset browser: assigning material '{}' to selection", rel);
1463 }
1464 }
1465 }
1466 if let Some(idx) = to_reveal {
1467 if let Some(rel) = self.flat.get(idx).map(|a| a.rel_path.clone()) {
1468 self.reveal_in_explorer(&rel, false);
1469 }
1470 }
1471 if let Some(idx) = to_rename {
1472 if let Some(asset) = self.flat.get(idx) {
1476 self.rename_buffer = asset.name.clone();
1477 self.renaming_index = Some(idx);
1478 self.rename_focus_pending = true;
1479 }
1480 }
1481 if let Some(rel) = to_duplicate {
1482 if let Ok(mut state) = self.state.lock() {
1483 state.pending_duplicate_asset = Some(rel.clone());
1484 }
1485 log::info!("Asset browser: duplicating '{rel}'");
1486 }
1487 if let Some(rel) = to_delete {
1488 self.confirm_delete = Some((rel, false));
1489 }
1490 if let Some(i) = to_select {
1491 self.selected_index = Some(i);
1492 if let Some(asset) = self.flat.get(i) {
1493 if let Ok(mut state) = self.state.lock() {
1494 state.inspected_asset_path = Some(asset.rel_path.clone());
1499 state.selected_asset = Some(i);
1500 state.clear_selection();
1501 state.inspected = None;
1502 }
1503 log::info!(
1504 "Asset selected: {} ({:?}) — {}",
1505 asset.name,
1506 asset.asset_type,
1507 asset.rel_path
1508 );
1509 }
1510 }
1511 if let Some(i) = to_activate {
1512 self.selected_index = Some(i);
1513 if let Some(asset) = self.flat.get(i).cloned() {
1514 self.activate_asset(&asset);
1515 }
1516 }
1517
1518 if visible.is_empty() {
1519 ui.paint_text_styled(
1520 [grid_inner_x + grid_inner_w * 0.5, grid_inner_y + 60.0],
1521 "No assets match the current filter.",
1522 12.0,
1523 theme.text_muted,
1524 FontFamilyHint::Proportional,
1525 TextAlign::Center,
1526 );
1527 }
1528
1529 if let Some(rename_idx) = self.renaming_index {
1533 let slot = visible
1534 .iter()
1535 .position(|(oi, _)| *oi == rename_idx)
1536 .map(|pos| {
1537 let col = pos % cols;
1538 let row = pos / cols;
1539 let tx = grid_inner_x + col as f32 * (TILE_SIZE + TILE_GAP);
1540 let ty = grid_inner_y + row as f32 * (tile_h + TILE_GAP);
1541 [tx + 3.0, ty + TILE_SIZE - 2.0, TILE_SIZE - 6.0, 20.0]
1542 });
1543 let asset = self.flat.get(rename_idx).cloned();
1545 match (slot, asset) {
1546 (Some(rect), Some(asset)) => {
1547 let focus = self.rename_focus_pending;
1548 let salt = format!("tile-rename-{rename_idx}");
1549 let buf = &mut self.rename_buffer;
1550 let event = ui.inline_text_field(rect, &salt, buf, focus);
1551 self.rename_focus_pending = false;
1552 match event {
1553 InlineEditEvent::Committed => {
1554 let edited = self.rename_buffer.trim().to_string();
1555 if !edited.is_empty() {
1556 let final_name = ensure_extension(&edited, &asset.name);
1557 let new_rel = if asset.folder.is_empty() {
1558 final_name
1559 } else {
1560 format!("{}/{}", asset.folder, final_name)
1561 };
1562 if new_rel != asset.rel_path {
1563 if let Ok(mut state) = self.state.lock() {
1564 state.pending_rename_asset =
1565 Some((asset.rel_path.clone(), new_rel));
1566 }
1567 log::info!("Asset browser: renaming '{}'", asset.rel_path);
1568 }
1569 }
1570 self.renaming_index = None;
1571 }
1572 InlineEditEvent::Cancelled => self.renaming_index = None,
1573 _ => {}
1574 }
1575 }
1576 _ => self.renaming_index = None,
1578 }
1579 }
1580
1581 let search_filter_ref = &mut self.search_filter;
1582 ui.region_at(
1583 "asset-browser-search",
1584 [search_x + 20.0, search_y, search_w - 22.0, 22.0],
1585 &mut |ui_inner| {
1586 ui_inner.text_edit_singleline(search_filter_ref);
1587 },
1588 );
1589
1590 self.render_delete_confirmation(ui, panel_rect, &theme);
1592 }
1593}