Skip to main content

proof_engine/editor/
asset_browser.rs

1//! Asset Browser — hierarchical filesystem view of saved SDF models, materials,
2//! animations, scenes, and shaders.
3//!
4//! # Overview
5//!
6//! The asset browser maintains a virtual directory tree rooted at the project's
7//! `assets/` folder.  It scans on startup and on explicit refresh, building a
8//! `DirNode` tree.  Each `AssetEntry` carries a `AssetKind` tag, thumbnail
9//! metadata, and import/export helpers.
10//!
11//! # Interaction
12//!
13//! - Clicking a folder expands/collapses it.
14//! - Double-clicking an asset opens it in the appropriate editor panel
15//!   (SDF node editor for `.sdf`, material painter for `.mat`, etc.).
16//! - Right-click opens a context menu: Rename, Duplicate, Delete, Show in
17//!   Explorer.
18//! - Drag from browser → viewport/hierarchy creates an instance.
19//! - The search bar filters by name and tags; results are shown flat.
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24// ─────────────────────────────────────────────────────────────────────────────
25// AssetKind
26// ─────────────────────────────────────────────────────────────────────────────
27
28/// The type of an asset file.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum AssetKind {
31    SdfGraph,      // .sdf  — node graph serialised as binary
32    Material,      // .mat  — material preset
33    Animation,     // .anim — AnimClip
34    Scene,         // .scene — full scene binary
35    SceneToml,     // .toml — human-readable scene
36    Shader,        // .glsl / .wgsl
37    Texture,       // .png / .jpg / .exr
38    Audio,         // .wav / .ogg / .flac
39    Script,        // .lua / .rhai
40    Prefab,        // .prefab — serialised entity hierarchy
41    Palette,       // .pal — color palette
42    Unknown,
43}
44
45impl AssetKind {
46    pub fn from_extension(ext: &str) -> Self {
47        match ext.to_lowercase().as_str() {
48            "sdf"    => AssetKind::SdfGraph,
49            "mat"    => AssetKind::Material,
50            "anim"   => AssetKind::Animation,
51            "scene"  => AssetKind::Scene,
52            "toml"   => AssetKind::SceneToml,
53            "glsl" | "wgsl" | "vert" | "frag" | "comp" => AssetKind::Shader,
54            "png" | "jpg" | "jpeg" | "exr" | "hdr"     => AssetKind::Texture,
55            "wav" | "ogg" | "flac" | "mp3"             => AssetKind::Audio,
56            "lua" | "rhai"                              => AssetKind::Script,
57            "prefab"                                    => AssetKind::Prefab,
58            "pal"                                       => AssetKind::Palette,
59            _                                           => AssetKind::Unknown,
60        }
61    }
62
63    pub fn icon(self) -> char {
64        match self {
65            AssetKind::SdfGraph  => 'S',
66            AssetKind::Material  => 'M',
67            AssetKind::Animation => 'A',
68            AssetKind::Scene     => 'W',
69            AssetKind::SceneToml => 'T',
70            AssetKind::Shader    => 'H',
71            AssetKind::Texture   => 'I',
72            AssetKind::Audio     => '♪',
73            AssetKind::Script    => 'L',
74            AssetKind::Prefab    => 'P',
75            AssetKind::Palette   => 'C',
76            AssetKind::Unknown   => '?',
77        }
78    }
79
80    pub fn label(self) -> &'static str {
81        match self {
82            AssetKind::SdfGraph  => "SDF Graph",
83            AssetKind::Material  => "Material",
84            AssetKind::Animation => "Animation",
85            AssetKind::Scene     => "Scene",
86            AssetKind::SceneToml => "Scene (TOML)",
87            AssetKind::Shader    => "Shader",
88            AssetKind::Texture   => "Texture",
89            AssetKind::Audio     => "Audio",
90            AssetKind::Script    => "Script",
91            AssetKind::Prefab    => "Prefab",
92            AssetKind::Palette   => "Palette",
93            AssetKind::Unknown   => "Unknown",
94        }
95    }
96
97    pub fn color_rgb(self) -> (f32, f32, f32) {
98        match self {
99            AssetKind::SdfGraph  => (0.5, 0.8, 1.0),
100            AssetKind::Material  => (1.0, 0.6, 0.2),
101            AssetKind::Animation => (0.8, 0.4, 1.0),
102            AssetKind::Scene     => (0.4, 1.0, 0.5),
103            AssetKind::SceneToml => (0.3, 0.9, 0.4),
104            AssetKind::Shader    => (1.0, 0.9, 0.3),
105            AssetKind::Texture   => (0.9, 0.9, 0.9),
106            AssetKind::Audio     => (0.6, 1.0, 0.8),
107            AssetKind::Script    => (1.0, 0.8, 0.5),
108            AssetKind::Prefab    => (1.0, 0.5, 0.5),
109            AssetKind::Palette   => (0.9, 0.6, 0.9),
110            AssetKind::Unknown   => (0.5, 0.5, 0.5),
111        }
112    }
113}
114
115// ─────────────────────────────────────────────────────────────────────────────
116// AssetId
117// ─────────────────────────────────────────────────────────────────────────────
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120pub struct AssetId(pub u64);
121
122impl AssetId {
123    /// Deterministic ID from a canonical path string.
124    pub fn from_path(p: &str) -> Self {
125        use std::collections::hash_map::DefaultHasher;
126        use std::hash::{Hash, Hasher};
127        let mut h = DefaultHasher::new();
128        p.hash(&mut h);
129        AssetId(h.finish())
130    }
131}
132
133// ─────────────────────────────────────────────────────────────────────────────
134// AssetEntry
135// ─────────────────────────────────────────────────────────────────────────────
136
137/// Metadata for a single asset file.
138#[derive(Debug, Clone)]
139pub struct AssetEntry {
140    pub id:          AssetId,
141    pub name:        String,
142    pub path:        PathBuf,
143    pub kind:        AssetKind,
144    pub size_bytes:  u64,
145    pub tags:        Vec<String>,
146    pub thumbnail:   Option<ThumbnailData>,
147    pub selected:    bool,
148    pub favourite:   bool,
149    /// True if the disk file is newer than the last cached scan.
150    pub dirty:       bool,
151}
152
153impl AssetEntry {
154    pub fn new(path: PathBuf) -> Self {
155        let name = path.file_name()
156            .map(|n| n.to_string_lossy().into_owned())
157            .unwrap_or_default();
158        let ext = path.extension()
159            .map(|e| e.to_string_lossy().into_owned())
160            .unwrap_or_default();
161        let kind = AssetKind::from_extension(&ext);
162        let id = AssetId::from_path(&path.to_string_lossy());
163        Self {
164            id, name, path, kind,
165            size_bytes: 0,
166            tags: Vec::new(),
167            thumbnail: None,
168            selected: false,
169            favourite: false,
170            dirty: false,
171        }
172    }
173
174    pub fn display_size(&self) -> String {
175        if self.size_bytes < 1024 { format!("{} B", self.size_bytes) }
176        else if self.size_bytes < 1024 * 1024 { format!("{:.1} KB", self.size_bytes as f64 / 1024.0) }
177        else { format!("{:.2} MB", self.size_bytes as f64 / (1024.0 * 1024.0)) }
178    }
179}
180
181/// Simple placeholder for a thumbnail — in a real implementation this would
182/// hold a GPU texture handle.
183#[derive(Debug, Clone)]
184pub struct ThumbnailData {
185    pub width:  u32,
186    pub height: u32,
187    pub pixels: Vec<u8>, // RGBA8
188}
189
190impl ThumbnailData {
191    pub fn placeholder(width: u32, height: u32, r: u8, g: u8, b: u8) -> Self {
192        let pixels = vec![r, g, b, 255u8].into_iter()
193            .cycle().take((width * height * 4) as usize).collect();
194        Self { width, height, pixels }
195    }
196}
197
198// ─────────────────────────────────────────────────────────────────────────────
199// DirNode
200// ─────────────────────────────────────────────────────────────────────────────
201
202/// A node in the virtual directory tree.
203#[derive(Debug)]
204pub struct DirNode {
205    pub path:     PathBuf,
206    pub name:     String,
207    pub expanded: bool,
208    pub children: Vec<DirNodeId>,
209    pub assets:   Vec<AssetId>,
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
213pub struct DirNodeId(pub u32);
214
215impl DirNode {
216    pub fn new(path: PathBuf) -> Self {
217        let name = path.file_name()
218            .map(|n| n.to_string_lossy().into_owned())
219            .unwrap_or_else(|| path.to_string_lossy().into_owned());
220        Self { path, name, expanded: false, children: Vec::new(), assets: Vec::new() }
221    }
222}
223
224// ─────────────────────────────────────────────────────────────────────────────
225// SortMode / ViewMode
226// ─────────────────────────────────────────────────────────────────────────────
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
229pub enum SortMode { #[default] Name, Kind, Size, Modified, Favourite }
230
231impl SortMode {
232    pub fn label(self) -> &'static str {
233        match self { Self::Name=>"Name", Self::Kind=>"Kind", Self::Size=>"Size",
234                     Self::Modified=>"Modified", Self::Favourite=>"Favourite" }
235    }
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
239pub enum ViewMode { #[default] Grid, List, Detail }
240
241impl ViewMode {
242    pub fn label(self) -> &'static str {
243        match self { Self::Grid=>"Grid", Self::List=>"List", Self::Detail=>"Detail" }
244    }
245}
246
247// ─────────────────────────────────────────────────────────────────────────────
248// Filter
249// ─────────────────────────────────────────────────────────────────────────────
250
251/// Active filter in the asset browser.
252#[derive(Debug, Clone, Default)]
253pub struct AssetFilter {
254    pub search:     String,
255    pub kinds:      Vec<AssetKind>,
256    pub tags:       Vec<String>,
257    pub favourites_only: bool,
258}
259
260impl AssetFilter {
261    pub fn matches(&self, entry: &AssetEntry) -> bool {
262        if self.favourites_only && !entry.favourite { return false; }
263        if !self.kinds.is_empty() && !self.kinds.contains(&entry.kind) { return false; }
264        if !self.tags.is_empty() {
265            let has_all = self.tags.iter().all(|t| entry.tags.contains(t));
266            if !has_all { return false; }
267        }
268        if !self.search.is_empty() {
269            let q = self.search.to_lowercase();
270            if !entry.name.to_lowercase().contains(&q) { return false; }
271        }
272        true
273    }
274}
275
276// ─────────────────────────────────────────────────────────────────────────────
277// AssetBrowser
278// ─────────────────────────────────────────────────────────────────────────────
279
280/// Top-level asset browser state.
281#[derive(Debug)]
282pub struct AssetBrowser {
283    pub root_path:      PathBuf,
284    dir_nodes:          Vec<DirNode>,
285    next_dir_id:        u32,
286    assets:             HashMap<AssetId, AssetEntry>,
287    pub root_dir:       DirNodeId,
288    pub active_dir:     DirNodeId,
289    pub selected:       Vec<AssetId>,
290    pub hovered:        Option<AssetId>,
291    pub filter:         AssetFilter,
292    pub sort_mode:      SortMode,
293    pub sort_ascending: bool,
294    pub view_mode:      ViewMode,
295    pub thumbnail_size: u32,
296    pub show_hidden:    bool,
297    /// In-progress drag: asset being dragged.
298    pub drag_asset:     Option<AssetId>,
299    /// Clipboard for copy/paste within the browser.
300    clipboard:          Vec<AssetId>,
301    /// Whether clipboard is a cut (move) or copy.
302    clipboard_cut:      bool,
303}
304
305impl AssetBrowser {
306    pub fn new(root: PathBuf) -> Self {
307        let root_node = DirNode { path: root.clone(), name: "assets".into(),
308            expanded: true, children: Vec::new(), assets: Vec::new() };
309        let root_id = DirNodeId(0);
310        Self {
311            root_path: root,
312            dir_nodes: vec![root_node],
313            next_dir_id: 1,
314            assets: HashMap::new(),
315            root_dir: root_id,
316            active_dir: root_id,
317            selected: Vec::new(),
318            hovered: None,
319            filter: AssetFilter::default(),
320            sort_mode: SortMode::default(),
321            sort_ascending: true,
322            view_mode: ViewMode::default(),
323            thumbnail_size: 96,
324            show_hidden: false,
325            drag_asset: None,
326            clipboard: Vec::new(),
327            clipboard_cut: false,
328        }
329    }
330
331    // ── Scan ──────────────────────────────────────────────────────────────
332
333    /// Simulate scanning a directory and adding entries.
334    pub fn mock_scan(&mut self) {
335        // Add some sample assets for testing / editor mock
336        let samples = vec![
337            ("body_scaffold.sdf",        AssetKind::SdfGraph,  1_024),
338            ("leon.sdf",                 AssetKind::SdfGraph,  8_192),
339            ("skin_default.mat",         AssetKind::Material,    512),
340            ("metal_chrome.mat",         AssetKind::Material,    512),
341            ("idle.anim",                AssetKind::Animation, 4_096),
342            ("walk_cycle.anim",          AssetKind::Animation, 8_192),
343            ("main_scene.scene",         AssetKind::Scene,   65_536),
344            ("main_scene.toml",          AssetKind::SceneToml, 8_192),
345            ("particle_vert.glsl",       AssetKind::Shader,   2_048),
346            ("sdf_body_frag.glsl",       AssetKind::Shader,  16_384),
347            ("albedo_atlas.png",         AssetKind::Texture, 256_000),
348            ("normal_map.png",           AssetKind::Texture, 256_000),
349            ("soundtrack.ogg",           AssetKind::Audio,   1_024_000),
350            ("game_logic.lua",           AssetKind::Script,   4_096),
351        ];
352
353        for (name, kind, size) in samples {
354            let path = self.root_path.join(name);
355            let mut entry = AssetEntry::new(path.clone());
356            entry.kind = kind;
357            entry.size_bytes = size;
358            let id = entry.id;
359            if let Some(dir) = self.dir_nodes.get_mut(0) {
360                dir.assets.push(id);
361            }
362            let (r, g, b) = kind.color_rgb();
363            entry.thumbnail = Some(ThumbnailData::placeholder(
364                self.thumbnail_size, self.thumbnail_size,
365                (r * 200.0) as u8, (g * 200.0) as u8, (b * 200.0) as u8,
366            ));
367            self.assets.insert(id, entry);
368        }
369    }
370
371    // ── Selection ─────────────────────────────────────────────────────────
372
373    pub fn select_only(&mut self, id: AssetId) {
374        for entry in self.assets.values_mut() { entry.selected = false; }
375        self.selected.clear();
376        self.selected.push(id);
377        if let Some(e) = self.assets.get_mut(&id) { e.selected = true; }
378    }
379
380    pub fn toggle_select(&mut self, id: AssetId) {
381        if let Some(pos) = self.selected.iter().position(|&x| x == id) {
382            self.selected.remove(pos);
383            if let Some(e) = self.assets.get_mut(&id) { e.selected = false; }
384        } else {
385            self.selected.push(id);
386            if let Some(e) = self.assets.get_mut(&id) { e.selected = true; }
387        }
388    }
389
390    pub fn select_all_in_active_dir(&mut self) {
391        let active = self.active_dir;
392        if let Some(dir) = self.dir_nodes.get(active.0 as usize) {
393            let ids: Vec<_> = dir.assets.clone();
394            for id in ids {
395                if !self.selected.contains(&id) { self.selected.push(id); }
396                if let Some(e) = self.assets.get_mut(&id) { e.selected = true; }
397            }
398        }
399    }
400
401    pub fn clear_selection(&mut self) {
402        for id in self.selected.drain(..) {
403            if let Some(e) = self.assets.get_mut(&id) { e.selected = false; }
404        }
405    }
406
407    // ── Filtered / sorted view ────────────────────────────────────────────
408
409    pub fn visible_assets(&self) -> Vec<&AssetEntry> {
410        let active = self.active_dir;
411        let dir_assets = self.dir_nodes.get(active.0 as usize)
412            .map(|d| d.assets.as_slice())
413            .unwrap_or(&[]);
414
415        let mut entries: Vec<&AssetEntry> = dir_assets.iter()
416            .filter_map(|id| self.assets.get(id))
417            .filter(|e| self.filter.matches(e))
418            .collect();
419
420        match self.sort_mode {
421            SortMode::Name     => entries.sort_by(|a, b| a.name.cmp(&b.name)),
422            SortMode::Kind     => entries.sort_by(|a, b| a.kind.label().cmp(b.kind.label())),
423            SortMode::Size     => entries.sort_by(|a, b| a.size_bytes.cmp(&b.size_bytes)),
424            SortMode::Favourite=> entries.sort_by(|a, b| b.favourite.cmp(&a.favourite)),
425            SortMode::Modified => {},
426        }
427        if !self.sort_ascending { entries.reverse(); }
428        entries
429    }
430
431    pub fn search_all(&self, query: &str) -> Vec<&AssetEntry> {
432        if query.is_empty() { return Vec::new(); }
433        let q = query.to_lowercase();
434        self.assets.values()
435            .filter(|e| e.name.to_lowercase().contains(&q))
436            .collect()
437    }
438
439    // ── Clipboard operations ──────────────────────────────────────────────
440
441    pub fn copy_selected(&mut self) {
442        self.clipboard = self.selected.clone();
443        self.clipboard_cut = false;
444    }
445
446    pub fn cut_selected(&mut self) {
447        self.clipboard = self.selected.clone();
448        self.clipboard_cut = true;
449    }
450
451    pub fn paste_count(&self) -> usize { self.clipboard.len() }
452
453    // ── Favourites ────────────────────────────────────────────────────────
454
455    pub fn toggle_favourite(&mut self, id: AssetId) {
456        if let Some(e) = self.assets.get_mut(&id) {
457            e.favourite = !e.favourite;
458        }
459    }
460
461    // ── Directory navigation ──────────────────────────────────────────────
462
463    pub fn navigate_to(&mut self, dir_id: DirNodeId) {
464        self.active_dir = dir_id;
465        self.clear_selection();
466    }
467
468    pub fn toggle_dir_expand(&mut self, dir_id: DirNodeId) {
469        if let Some(dir) = self.dir_nodes.get_mut(dir_id.0 as usize) {
470            dir.expanded = !dir.expanded;
471        }
472    }
473
474    // ── Accessors ─────────────────────────────────────────────────────────
475
476    pub fn get(&self, id: AssetId) -> Option<&AssetEntry> { self.assets.get(&id) }
477    pub fn get_mut(&mut self, id: AssetId) -> Option<&mut AssetEntry> { self.assets.get_mut(&id) }
478    pub fn total_assets(&self) -> usize { self.assets.len() }
479
480    pub fn asset_count_by_kind(&self) -> HashMap<AssetKind, usize> {
481        let mut counts: HashMap<AssetKind, usize> = HashMap::new();
482        for e in self.assets.values() {
483            *counts.entry(e.kind).or_insert(0) += 1;
484        }
485        counts
486    }
487
488    // ── Status ────────────────────────────────────────────────────────────
489
490    pub fn status_line(&self) -> String {
491        let visible = self.visible_assets().len();
492        let total   = self.total_assets();
493        let sel     = self.selected.len();
494        format!(
495            "Assets — {visible} visible / {total} total | {sel} selected | {:?} view | sort by {}{}",
496            self.view_mode,
497            self.sort_mode.label(),
498            if self.sort_ascending { " ↑" } else { " ↓" }
499        )
500    }
501}
502
503// ─────────────────────────────────────────────────────────────────────────────
504// ImportOptions — controls for asset import pipeline
505// ─────────────────────────────────────────────────────────────────────────────
506
507/// Options for importing an asset.
508#[derive(Debug, Clone)]
509pub struct ImportOptions {
510    pub target_dir:     PathBuf,
511    pub overwrite:      bool,
512    pub generate_thumb: bool,
513    pub normalize:      bool,
514    pub tags:           Vec<String>,
515}
516
517impl Default for ImportOptions {
518    fn default() -> Self {
519        Self {
520            target_dir:     PathBuf::from("assets"),
521            overwrite:      false,
522            generate_thumb: true,
523            normalize:      false,
524            tags:           Vec::new(),
525        }
526    }
527}
528
529// ─────────────────────────────────────────────────────────────────────────────
530// Tests
531// ─────────────────────────────────────────────────────────────────────────────
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536
537    fn test_browser() -> AssetBrowser {
538        let mut b = AssetBrowser::new(PathBuf::from("assets"));
539        b.mock_scan();
540        b
541    }
542
543    #[test]
544    fn scan_populates_assets() {
545        let b = test_browser();
546        assert!(b.total_assets() > 0);
547    }
548
549    #[test]
550    fn filter_by_kind() {
551        let mut b = test_browser();
552        b.filter.kinds = vec![AssetKind::SdfGraph];
553        let visible = b.visible_assets();
554        assert!(visible.iter().all(|e| e.kind == AssetKind::SdfGraph));
555    }
556
557    #[test]
558    fn filter_by_name() {
559        let mut b = test_browser();
560        b.filter.search = "leon".into();
561        let results = b.search_all("leon");
562        assert!(!results.is_empty());
563    }
564
565    #[test]
566    fn select_toggle() {
567        let mut b = test_browser();
568        let id = *b.assets.keys().next().unwrap();
569        b.select_only(id);
570        assert_eq!(b.selected.len(), 1);
571        b.toggle_select(id);
572        assert_eq!(b.selected.len(), 0);
573    }
574
575    #[test]
576    fn asset_kind_from_extension() {
577        assert_eq!(AssetKind::from_extension("sdf"),  AssetKind::SdfGraph);
578        assert_eq!(AssetKind::from_extension("mat"),  AssetKind::Material);
579        assert_eq!(AssetKind::from_extension("anim"), AssetKind::Animation);
580        assert_eq!(AssetKind::from_extension("png"),  AssetKind::Texture);
581    }
582
583    #[test]
584    fn colour_hex_parse() {
585        use crate::editor::kit_panel::ColorPicker;
586        let c = ColorPicker::new("t", glam::Vec4::new(1.0, 0.0, 0.0, 1.0));
587        let hex = c.to_hex();
588        let back = ColorPicker::from_hex(&hex).unwrap();
589        assert!((back.x - 1.0).abs() < 0.01);
590    }
591}