use crate::mosaic::WidgetContext;
use nightshade::prelude::serde::{Deserialize, Serialize};
use nightshade::prelude::*;
use crate::app_context::AppContext;
use crate::messages::EditorMessage;
#[derive(Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(crate = "nightshade::prelude::serde")]
pub enum AssetBrowserViewMode {
#[default]
Grid,
List,
}
#[derive(Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(crate = "nightshade::prelude::serde")]
pub enum AssetFilter {
#[default]
All,
Meshes,
Textures,
Materials,
Prefabs,
Scripts,
Audio,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum AssetCategory {
Mesh,
Texture,
Material,
Prefab,
Script,
Audio,
Project,
Other,
}
pub(crate) fn categorize_asset(name: &str) -> AssetCategory {
let lower = name.to_lowercase();
if lower.ends_with(".project.json") || lower.ends_with(".project.bin") {
return AssetCategory::Project;
}
if lower.ends_with(".gltf")
|| lower.ends_with(".glb")
|| lower.ends_with(".fbx")
|| lower.ends_with(".obj")
{
AssetCategory::Mesh
} else if lower.ends_with(".png")
|| lower.ends_with(".jpg")
|| lower.ends_with(".jpeg")
|| lower.ends_with(".hdr")
{
AssetCategory::Texture
} else if lower.ends_with(".mat") || lower.ends_with(".material") {
AssetCategory::Material
} else if lower.ends_with(".prefab") || lower.ends_with(".json") || lower.ends_with(".bin") {
AssetCategory::Prefab
} else if lower.ends_with(".rhai") || lower.ends_with(".lua") {
AssetCategory::Script
} else if lower.ends_with(".wav") || lower.ends_with(".mp3") || lower.ends_with(".ogg") {
AssetCategory::Audio
} else {
AssetCategory::Other
}
}
fn asset_icon(category: AssetCategory) -> &'static str {
match category {
AssetCategory::Mesh => "\u{1f3ae}",
AssetCategory::Texture => "\u{1f5bc}",
AssetCategory::Script => "\u{1f4dc}",
AssetCategory::Audio => "\u{1f50a}",
AssetCategory::Material
| AssetCategory::Prefab
| AssetCategory::Project
| AssetCategory::Other => "\u{1f4c4}",
}
}
fn matches_filter(category: AssetCategory, filter: AssetFilter) -> bool {
match filter {
AssetFilter::All => true,
AssetFilter::Meshes => category == AssetCategory::Mesh,
AssetFilter::Textures => category == AssetCategory::Texture,
AssetFilter::Materials => category == AssetCategory::Material,
AssetFilter::Prefabs => category == AssetCategory::Prefab,
AssetFilter::Scripts => category == AssetCategory::Script,
AssetFilter::Audio => category == AssetCategory::Audio,
}
}
#[derive(Clone)]
struct CachedEntry {
path: std::path::PathBuf,
file_name: String,
file_name_lower: String,
is_dir: bool,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(crate = "nightshade::prelude::serde")]
pub struct AssetBrowserWidget {
#[serde(skip)]
pub current_path: std::path::PathBuf,
#[serde(skip)]
pub search_query: String,
pub view_mode: AssetBrowserViewMode,
pub filter: AssetFilter,
#[serde(skip)]
pub selected_asset: Option<std::path::PathBuf>,
#[serde(skip)]
cached_entries: Vec<CachedEntry>,
#[serde(skip)]
cache_path: std::path::PathBuf,
#[serde(skip)]
filtered_indices: Vec<usize>,
}
impl Default for AssetBrowserWidget {
fn default() -> Self {
Self {
current_path: std::path::PathBuf::new(),
search_query: String::new(),
view_mode: AssetBrowserViewMode::Grid,
filter: AssetFilter::All,
selected_asset: None,
cached_entries: Vec::new(),
cache_path: std::path::PathBuf::new(),
filtered_indices: Vec::new(),
}
}
}
impl AssetBrowserWidget {
pub(crate) fn render(
&mut self,
ui: &mut egui::Ui,
context: &mut WidgetContext<AppContext, EditorMessage>,
) {
let tile_id = context.current_tile_id;
let available_rect = ui.available_rect_before_wrap();
ui.painter()
.rect_filled(available_rect, 0.0, ui.style().visuals.panel_fill);
egui::Panel::top(egui::Id::new(("asset_browser_toolbar", tile_id))).show_inside(ui, |ui| {
ui.horizontal(|ui| {
ui.label("Search:");
ui.text_edit_singleline(&mut self.search_query);
ui.separator();
egui::ComboBox::from_id_salt(("asset_filter", tile_id))
.selected_text(match self.filter {
AssetFilter::All => "All",
AssetFilter::Meshes => "Meshes",
AssetFilter::Textures => "Textures",
AssetFilter::Materials => "Materials",
AssetFilter::Prefabs => "Prefabs",
AssetFilter::Scripts => "Scripts",
AssetFilter::Audio => "Audio",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut self.filter, AssetFilter::All, "All");
ui.selectable_value(&mut self.filter, AssetFilter::Meshes, "Meshes");
ui.selectable_value(&mut self.filter, AssetFilter::Textures, "Textures");
ui.selectable_value(&mut self.filter, AssetFilter::Materials, "Materials");
ui.selectable_value(&mut self.filter, AssetFilter::Prefabs, "Prefabs");
ui.selectable_value(&mut self.filter, AssetFilter::Scripts, "Scripts");
ui.selectable_value(&mut self.filter, AssetFilter::Audio, "Audio");
});
ui.separator();
if ui
.selectable_label(self.view_mode == AssetBrowserViewMode::Grid, "Grid")
.clicked()
{
self.view_mode = AssetBrowserViewMode::Grid;
}
if ui
.selectable_label(self.view_mode == AssetBrowserViewMode::List, "List")
.clicked()
{
self.view_mode = AssetBrowserViewMode::List;
}
});
});
let project_path = context.app.project_edit.current_path.as_ref();
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(ui.style().visuals.panel_fill))
.show_inside(ui, |ui| {
egui::ScrollArea::vertical()
.id_salt(("asset_browser_scroll", tile_id))
.auto_shrink([false, false])
.show(ui, |ui| {
self.show_assets(ui, project_path, tile_id);
});
});
}
fn show_assets(
&mut self,
ui: &mut egui::Ui,
project_path: Option<&std::path::PathBuf>,
tile_id: egui_tiles::TileId,
) {
let project_path = match project_path {
Some(path) => path.clone(),
None => {
ui.centered_and_justified(|ui| {
ui.label("No project loaded");
});
return;
}
};
let assets_path = project_path.join("assets");
if !assets_path.exists() {
ui.centered_and_justified(|ui| {
ui.label("No assets folder found");
});
return;
}
if self.current_path.as_os_str().is_empty() {
self.current_path = assets_path.clone();
}
ui.horizontal(|ui| {
if ui.button("<<").clicked()
&& self.current_path != assets_path
&& let Some(parent) = self.current_path.parent()
&& (parent.starts_with(&assets_path) || parent == assets_path)
{
self.current_path = parent.to_path_buf();
}
ui.label(
self.current_path
.strip_prefix(&project_path)
.unwrap_or(&self.current_path)
.display()
.to_string(),
);
});
ui.separator();
if self.cache_path != self.current_path {
match std::fs::read_dir(&self.current_path) {
Ok(entries) => {
self.cached_entries = entries
.filter_map(|entry| entry.ok())
.map(|entry| {
let path = entry.path();
let is_dir = path.is_dir();
let file_name = entry.file_name().to_string_lossy().to_string();
let file_name_lower = file_name.to_lowercase();
CachedEntry {
path,
file_name,
file_name_lower,
is_dir,
}
})
.collect();
self.cache_path = self.current_path.clone();
}
Err(_) => {
ui.label("Failed to read directory");
return;
}
}
}
let search_lower = self.search_query.to_lowercase();
self.filtered_indices.clear();
self.filtered_indices.extend(
self.cached_entries
.iter()
.enumerate()
.filter(|(_, entry)| {
if !search_lower.is_empty() && !entry.file_name_lower.contains(&search_lower) {
return false;
}
if entry.is_dir {
return true;
}
matches_filter(categorize_asset(&entry.file_name), self.filter)
})
.map(|(index, _)| index),
);
match self.view_mode {
AssetBrowserViewMode::Grid => self.show_grid_view(ui, tile_id),
AssetBrowserViewMode::List => self.show_list_view(ui),
}
}
fn show_grid_view(&mut self, ui: &mut egui::Ui, tile_id: egui_tiles::TileId) {
let item_size = 80.0;
let spacing = 8.0;
let available_width = ui.available_width();
let items_per_row = ((available_width + spacing) / (item_size + spacing))
.floor()
.max(1.0) as usize;
egui::Grid::new(("asset_browser_grid", tile_id))
.num_columns(items_per_row)
.spacing([spacing, spacing])
.show(ui, |ui| {
for (index, &entry_index) in self.filtered_indices.iter().enumerate() {
let entry = &self.cached_entries[entry_index];
let path = &entry.path;
let is_dir = entry.is_dir;
let name = &entry.file_name;
let is_selected = self.selected_asset.as_ref() == Some(path);
let icon = if is_dir {
"\u{1f4c1}"
} else {
asset_icon(categorize_asset(name))
};
let fill_color = if is_selected {
ui.visuals().selection.bg_fill
} else {
ui.visuals().extreme_bg_color
};
let is_draggable = !is_dir && Self::is_spawnable_asset(path);
let item_id = egui::Id::new(("asset_item", tile_id, index));
let response = ui.scope(|ui| {
ui.set_min_width(item_size);
ui.set_max_width(item_size);
let frame = egui::Frame::NONE
.fill(fill_color)
.inner_margin(egui::Margin::same(4));
frame.show(ui, |ui| {
ui.centered_and_justified(|ui| {
ui.label(egui::RichText::new(icon).size(32.0));
});
});
let truncated_name = if name.chars().count() > 12 {
let end = name
.char_indices()
.nth(9)
.map(|(index, _)| index)
.unwrap_or(name.len());
format!("{}...", &name[..end])
} else {
name.clone()
};
ui.centered_and_justified(|ui| {
ui.label(egui::RichText::new(&truncated_name).small())
.on_hover_text(name.as_str());
});
});
let item_rect = response.response.rect;
let item_response =
ui.interact(item_rect, item_id, egui::Sense::click_and_drag());
if is_draggable {
if item_response.drag_started() {
ui.ctx().data_mut(|data| {
data.insert_temp(egui::Id::new("dragged_asset_path"), path.clone());
});
}
if item_response.dragged() {
egui::Area::new(egui::Id::new("asset_drag_preview"))
.order(egui::Order::Tooltip)
.current_pos(ui.ctx().pointer_hover_pos().unwrap_or_default())
.show(ui.ctx(), |ui| {
ui.label(format!("{} {}", icon, name));
});
}
}
if item_response.clicked() {
if is_dir {
self.current_path = path.clone();
self.selected_asset = None;
} else {
self.selected_asset = Some(path.clone());
}
}
if (index + 1) % items_per_row == 0 {
ui.end_row();
}
}
});
}
fn is_spawnable_asset(path: &std::path::Path) -> bool {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
categorize_asset(name) == AssetCategory::Mesh
}
fn show_list_view(&mut self, ui: &mut egui::Ui) {
for (index, &entry_index) in self.filtered_indices.iter().enumerate() {
let entry = &self.cached_entries[entry_index];
let is_selected = self.selected_asset.as_ref() == Some(&entry.path);
let icon = if entry.is_dir {
"\u{1f4c1}"
} else {
asset_icon(categorize_asset(&entry.file_name))
};
let is_draggable = !entry.is_dir && Self::is_spawnable_asset(&entry.path);
let item_id = egui::Id::new(("asset_list_item", index));
let response =
ui.selectable_label(is_selected, format!("{} {}", icon, entry.file_name));
let item_response = ui.interact(response.rect, item_id, egui::Sense::drag());
if is_draggable {
if item_response.drag_started() {
ui.ctx().data_mut(|data| {
data.insert_temp(egui::Id::new("dragged_asset_path"), entry.path.clone());
});
}
if item_response.dragged() {
egui::Area::new(egui::Id::new("asset_drag_preview"))
.order(egui::Order::Tooltip)
.current_pos(ui.ctx().pointer_hover_pos().unwrap_or_default())
.show(ui.ctx(), |ui| {
ui.label(format!("{} {}", icon, entry.file_name));
});
}
}
if response.clicked() {
if entry.is_dir {
self.current_path = entry.path.clone();
self.selected_asset = None;
} else {
self.selected_asset = Some(entry.path.clone());
}
}
}
}
}