1use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum AssetKind {
31 SdfGraph, Material, Animation, Scene, SceneToml, Shader, Texture, Audio, Script, Prefab, Palette, 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
120pub struct AssetId(pub u64);
121
122impl AssetId {
123 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#[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 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#[derive(Debug, Clone)]
184pub struct ThumbnailData {
185 pub width: u32,
186 pub height: u32,
187 pub pixels: Vec<u8>, }
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#[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#[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#[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#[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 pub drag_asset: Option<AssetId>,
299 clipboard: Vec<AssetId>,
301 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 pub fn mock_scan(&mut self) {
335 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 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 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 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 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 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 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 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#[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#[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}