Skip to main content

khora_editor/panels/asset_browser/
mod.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//! Asset Browser — folder explorer + grid + per-asset inspection.
16//!
17//! Layout:
18//!
19//! ```text
20//! ┌──────────────┬───────────────────────────────────┐
21//! │  Categories  │  ← / breadcrumb / search →        │
22//! │  (filters)   ├───────────────────────────────────┤
23//! ├──────────────┤  Tile grid                        │
24//! │  Folder tree │                                   │
25//! │  (real VFS)  │                                   │
26//! └──────────────┴───────────────────────────────────┘
27//! ```
28//!
29//! Single-click on a tile sets `EditorState::inspected_asset_path` so
30//! the Inspector switches to asset-metadata mode (Phase 5). Double-click
31//! still routes through the [`handlers::AssetTypeHandler`] activation
32//! — `LoadScene` for `.kscene`, `OpenExternal` for everything else.
33
34pub 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
47/// High 32 bits of a `u64` drag payload that identifies an asset-browser
48/// tile — the low 32 bits hold the asset index in `EditorState::asset_entries`.
49/// Every tile is a drag source; the drop sink (viewport / folder tree)
50/// dispatches by the asset's type, so the tag is type-agnostic. Picked so
51/// it can't collide with the `EntityId`-packed payload used by the
52/// scene-tree reparent flow (entity generations are u32 and start at 1, so
53/// any value with `0xKHPF` in the top half can only mean "asset tile").
54pub(crate) const ASSET_DRAG_TAG: u64 = 0x4B48_5046_0000_0000; // "KHPF" in ASCII
55
56/// Walks a `SceneNode` forest to find `entity`'s display name. Used by
57/// the asset browser's drop receiver to compose a default `.kprefab`
58/// filename without a round-trip through the live `World`.
59fn 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
80/// Strips characters that aren't safe in cross-platform file names
81/// (path separators, shell-meta, control chars). Falls back to an
82/// underscore for runs of stripped chars so consecutive replacements
83/// don't collapse into nothing.
84fn 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
105/// Number of low bits of `EditorState::asset_epoch` stamped into a drag
106/// payload. Eight is plenty: the stamp only has to survive one drag, and the
107/// epoch would have to advance 256 times mid-gesture to alias.
108const DRAG_EPOCH_BITS: u32 = 8;
109const DRAG_INDEX_MASK: u64 = (1 << (32 - DRAG_EPOCH_BITS)) - 1;
110
111/// Packs an asset-list index plus a stamp of the epoch it was read at.
112///
113/// The index alone is not enough: it points into `EditorState::asset_entries`,
114/// which `hot_reload::pump` rebuilds whenever a file appears, disappears or is
115/// renamed on disk. Without the stamp, a rescan mid-drag silently retargets the
116/// drop at whatever now occupies that slot.
117pub(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
122/// Whether `payload` carries the asset tag, regardless of how stale it is.
123///
124/// Kept separate from [`unpack_asset_drag`] because the two answer different
125/// questions: this one classifies the payload, the other resolves it. Routing
126/// classification through the epoch check would make a stale asset drag look
127/// like a packed `EntityId` and get handled as a reparent.
128pub(crate) fn is_asset_drag(payload: u64) -> bool {
129    payload & 0xFFFF_FFFF_0000_0000 == ASSET_DRAG_TAG
130}
131
132/// Unpacks an asset drag payload, rejecting it when the asset list changed
133/// since the drag started.
134pub(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
146/// Re-applies the original file's extension to a user-edited name when the
147/// user omitted one (so renaming `crate.png` to `box` yields `box.png`, but
148/// `box.jpg` is honoured verbatim).
149fn 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
159/// A folder-tree context-menu / drag-drop action, collected during the
160/// (`&self`) tree walk and applied afterwards where `&mut self` is available.
161enum FolderAction {
162    /// Create a "New Folder" child under this folder (`""` = assets root).
163    NewFolder(String),
164    /// Send this folder to the OS recycle bin.
165    Delete(String),
166    /// Open this folder in the OS file explorer.
167    Reveal(String),
168    /// Begin inline-renaming this folder, seeding the buffer with its name.
169    StartRename { path: String, name: String },
170    /// Move the asset at `flat` index `idx` into this folder.
171    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    /// Display name (file name component).
185    name: String,
186    /// Forward-slash relative path under `<project>/assets/`.
187    rel_path: String,
188    /// Folder containing the asset (forward-slash, no trailing /). `""`
189    /// for assets that sit directly under `assets/`.
190    folder: String,
191    asset_type: AssetTileKind,
192    type_name: String,
193}
194
195/// One node of the folder tree built from the flat asset list.
196#[derive(Debug, Default)]
197struct FolderNode {
198    /// Forward-slash full path (relative to `assets/`). `""` for the root.
199    full_path: String,
200    /// Last segment (display name). `""` for the root.
201    name: String,
202    /// Direct children, keyed by name (sorted by `BTreeMap`).
203    children: BTreeMap<String, FolderNode>,
204    /// Number of assets reachable from this folder (recursive total).
205    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    /// Every real directory under `assets/` (forward-slash, relative), mirrored
214    /// from `EditorState::asset_dirs` so empty / freshly-created folders show up
215    /// in the tree even though the file-only VFS never lists them.
216    asset_dirs: Vec<String>,
217    /// Last `EditorState::asset_epoch` we rebuilt `flat` for; a mismatch drives
218    /// the rescan (an in-place edit that doesn't change the entry count used to
219    /// be missed by the old `(folder, len)` key).
220    last_epoch: Option<u64>,
221    selected_filter: Option<AssetTileKind>,
222    selected_index: Option<usize>,
223    /// `flat` index of the tile currently being inline-renamed, if any.
224    renaming_index: Option<usize>,
225    /// Edit buffer backing both the tile and folder inline-rename fields.
226    rename_buffer: String,
227    /// Forward-slash path of the folder currently being inline-renamed, if any.
228    renaming_folder: Option<String>,
229    /// Edit buffer for the folder inline-rename field.
230    folder_rename_buffer: String,
231    /// `true` on the frame a rename starts, so the inline field grabs keyboard
232    /// focus once (not every frame, which would trap focus).
233    rename_focus_pending: bool,
234    /// Forward-slash folder path (relative to `assets/`) currently
235    /// selected in the tree. `""` = root, `None` initially.
236    current_folder: Option<String>,
237    /// Per-folder expand/collapse state. Keys are full paths; missing =
238    /// collapsed (root is special-cased to start expanded).
239    expanded_folders: std::collections::HashMap<String, bool>,
240    /// Path awaiting the delete confirmation, and whether it names a folder.
241    ///
242    /// Every delete route parks its target here instead of writing
243    /// `EditorState::pending_delete_asset` directly, so the recycle-bin call
244    /// only happens once the user has answered the dialog.
245    confirm_delete: Option<(String, bool)>,
246    /// How far the tile grid is scrolled.
247    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    /// Asks before sending anything to the recycle bin, and only then queues
274    /// the deletion for `commands::process_pending_asset_file_ops`.
275    ///
276    /// Deleting a file is the one action here the editor cannot undo — there is
277    /// no undo history, and the file leaves the project. A folder takes
278    /// everything under it, so its wording says so.
279    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        // Rescan only when the shared epoch advances — cheaper than diffing and
349        // it also catches in-place file edits that leave the entry count fixed.
350        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        // A rebuild reshuffles indices — abandon any in-flight tile rename.
362        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        // Union in every real directory (including empty and freshly-created
419        // ones the file-only VFS can't enumerate). These are pure structure —
420        // no assets to count — so we only create the missing nodes.
421        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    /// Resolves a forward-slash path under `assets/` to an absolute OS path,
447    /// or `None` when no project is open.
448    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    /// Opens the OS file explorer at `rel`. For a file we reveal its containing
465    /// folder; for a directory we open the directory itself.
466    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    /// Opens the project root in the OS file explorer.
485    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    /// Queues creation of a uniquely-named "New Folder" under `parent`
502    /// (`""` = assets root), disambiguating against known directories.
503    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    /// Paints a small chip that follows the cursor while a tile is being
525    /// dragged, so it's obvious the user is carrying an asset. Drawn on top of
526    /// the grid, offset from the pointer so the cursor stays visible.
527    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        // Painted in the unclipped top overlay layer so the ghost stays visible
544        // once the cursor leaves the asset-browser panel (dragging toward the
545        // folder tree or the 3D viewport). Soft shadow, body, accent border.
546        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    /// Queues saving `entity`'s subtree as a `.kprefab` in the current folder,
582    /// using the entity's display name as the file stem. Shared by the
583    /// grid-wide drop target and the per-tile entity-drop handler.
584    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    /// Render the recursive folder tree in the sidebar's lower panel.
640    /// Returns the y coordinate after the last rendered row.
641    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        // Window-space rect of the folder row being inline-renamed (if any),
655        // captured during the walk so we can overlay a text field afterwards
656        // where `&mut self` (hence the shared edit buffer) is available.
657        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        // Apply folder context-menu / drop actions collected during the walk.
679        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        // Inline folder-rename overlay, drawn over the captured row rect.
703        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        // Context menu (attached to this row's response) and folder drop target
775        // (an asset dragged onto a folder is a move). Both run before any child
776        // row registers its own `interact_rect`, so `last_response` still points
777        // at this row.
778        {
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        // A droppable target: while an asset is being dragged and this row is
815        // hovered, show an accent fill + border so it's obvious a drop here
816        // moves the file into this folder.
817        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            // Leave the label blank and hand its rect to the caller, which
871            // overlays a text field there once `&mut self` is available.
872            *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            // Click on a folder also flips its expand state (handy on
902            // narrow sidebars where the chevron is hard to hit).
903            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    /// Render the navigable breadcrumb (`project › assets › subfolder ›`).
928    /// Each segment is clickable — sets `current_folder`.
929    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        // ── Header ────────────────────────────────────
1028        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        // Only the count: the dock tab above already says "Asset Browser".
1032        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        // Header action buttons. Trash deletes the current selection; Filter
1041        // and More open small context menus. Effects are collected here and
1042        // applied after the loop so the closures don't need `&mut self`.
1043        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        // ── Layout: sidebar | grid ────────────────────
1112        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        // Type-category filter rows (compact, top of sidebar).
1130        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        // Tree separator label.
1198        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        // Folder tree (real VFS hierarchy).
1203        let _ = self.render_folder_tree(ui, px, row_y, sidebar_w, &theme);
1204
1205        // ── Grid area ─────────────────────────────────
1206        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        // Breadcrumb on the left, capped to leave room for the search.
1234        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        // Search affordance icon.
1245        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        // ── Tile grid ─────────────────────────────────
1254        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                // When a folder is selected, only show assets that live
1273                // *within* it (recursive — `assets/props` matches
1274                // `assets/props/crates/a.png`).
1275                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        // Drop target for entity drags from the scene tree. Registered
1286        // *before* the tile loop so the per-tile `interact_rect` calls
1287        // come later and take click priority — without this ordering
1288        // the grid-wide rect overlays the tiles and absorbs single
1289        // clicks (regression: tiles unselectable). The drop check
1290        // happens immediately, while `last_response` still points at
1291        // the grid rect; tiles registering after won't change the
1292        // already-read drop status.
1293        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        // Right-click on the empty grid background. Bound to the same grid-wide
1305        // response as the drop target above; per-tile menus (registered later,
1306        // on top) take precedence when the cursor is over a tile.
1307        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        // Name + kind of the tile currently being dragged, so we can paint a
1338        // cursor-following ghost after the grid (a visible "I'm carrying this").
1339        let mut dragging_ghost: Option<(String, AssetTileKind)> = None;
1340        // A scene-tree entity dropped onto a tile → save it as a prefab here.
1341        let mut entity_drop: Option<khora_sdk::prelude::ecs::EntityId> = None;
1342        // Tiles are placed at computed rects, so the grid scrolls by offsetting
1343        // its own origin and clipping — same shape as the Console and the
1344        // Hierarchy.
1345        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            // Skip whole rows scrolled out of view rather than painting them
1358            // under the clip.
1359            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            // Every tile is a drag source — the low 32 bits carry the index
1374            // into `EditorState::asset_entries`. The drop sink (viewport /
1375            // folder tree) dispatches by the asset's type.
1376            // Stamped with the epoch `flat` was built for — that is the epoch
1377            // `orig_idx` is an index into.
1378            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            // A scene-tree entity released ONTO this tile saves it as a prefab.
1386            // Tiles blanket the populated grid, so without this the entity would
1387            // land on a tile (top hovered) and the grid-wide drop rect below
1388            // would never see it.
1389            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            // Generic per-tile context menu (same idiom the scene tree uses
1395            // for entity actions). Material tiles keep their "Assign to
1396            // selected" entry on top of the shared actions.
1397            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        // Cursor-following drag ghost — painted after the clip is popped so it
1444        // can follow the cursor outside the grid.
1445        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            // Seed the shared buffer with the current file name and switch this
1473            // tile into inline-edit mode; the overlay is drawn near the search
1474            // box handling at the end of `ui()`.
1475            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                    // Single-click switches the inspector to asset
1495                    // metadata mode (Phase 5). Clearing entity
1496                    // selection prevents the entity inspector from
1497                    // ghosting under the asset metadata pane.
1498                    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        // Inline tile-rename overlay: a text field painted over the renaming
1530        // tile's label. Committed on Enter, cancelled on Escape (the builder
1531        // doesn't surface focus-loss, so those two keys are the commit path).
1532        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            // Snapshot the asset before taking the `&mut` buffer borrow.
1544            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                // The tile was filtered out or removed — abandon the rename.
1577                _ => 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        // Painted last so it sits above the grid it is asking about.
1591        self.render_delete_confirmation(ui, panel_rect, &theme);
1592    }
1593}