use std::path::{Path, PathBuf};
use std::sync::{Mutex, mpsc};
use bevy::{
asset::RenderAssetUsages,
image::{CompressedImageFormats, ImageSampler, ImageType},
prelude::*,
render::render_resource::{Extent3d, TextureDimension, TextureSampleType},
tasks::{AsyncComputeTaskPool, Task, futures_lite::future},
ui_widgets::observe,
window::{PrimaryWindow, RawHandleWrapper},
};
use jackdaw_feathers::{file_browser, icons, icons::IconFont, popover, tokens};
use jackdaw_widgets::file_browser::{FileBrowserItem, FileItemDoubleClicked};
use rfd::AsyncFileDialog;
use crate::{
EditorEntity,
brush::{Brush, BrushEditMode, BrushSelection, EditMode, LastUsedMaterial, SetBrush},
commands::CommandHistory,
material_browser::{MaterialRegistry, pbr_filename_regex},
selection::Selection,
};
pub fn is_ktx2_non_2d(path: &Path) -> bool {
let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
use std::io::Read;
let mut header = [0u8; 40];
if file.read_exact(&mut header).is_err() {
return false;
}
let pixel_depth = u32::from_le_bytes([header[28], header[29], header[30], header[31]]);
let layer_count = u32::from_le_bytes([header[32], header[33], header[34], header[35]]);
let face_count = u32::from_le_bytes([header[36], header[37], header[38], header[39]]);
pixel_depth > 0 || layer_count > 1 || face_count > 1
}
#[derive(Resource)]
struct DirectoryWatcher {
_watcher: notify::RecommendedWatcher,
receiver: Mutex<mpsc::Receiver<()>>,
}
fn setup_directory_watcher(root: &Path, commands: &mut Commands) {
let (tx, rx) = mpsc::channel();
let watcher = notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
if let Ok(event) = res {
use notify::EventKind;
if matches!(
event.kind,
EventKind::Create(_)
| EventKind::Remove(_)
| EventKind::Modify(notify::event::ModifyKind::Name(_))
) {
let _ = tx.send(());
}
}
});
match watcher {
Ok(mut w) => {
use notify::Watcher;
if w.watch(root, notify::RecursiveMode::Recursive).is_ok() {
commands.insert_resource(DirectoryWatcher {
_watcher: w,
receiver: Mutex::new(rx),
});
} else {
warn!("Failed to watch directory: {:?}", root);
}
}
Err(e) => {
warn!("Failed to create directory watcher: {}", e);
}
}
}
pub struct AssetBrowserPlugin;
impl Plugin for AssetBrowserPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AssetBrowserState>()
.init_resource::<AssetPreviewState>()
.init_resource::<ActiveTooltip>()
.add_systems(OnEnter(crate::AppState::Editor), setup_initial_directory)
.add_systems(
Update,
(
refresh_browser_on_change,
poll_asset_browser_folder,
extract_array_layers,
update_preview_panel,
check_watcher_events,
remove_incompatible_image_nodes,
)
.run_if(in_state(crate::AppState::Editor)),
)
.add_observer(handle_file_double_click)
.add_observer(handle_apply_texture)
.add_observer(handle_select_asset_preview);
}
}
#[derive(Event, Debug, Clone)]
pub struct ApplyTextureToFaces {
pub path: String,
}
#[derive(Event, Debug, Clone)]
pub struct ClearTextureFromFaces;
#[derive(Clone, Debug)]
pub struct TextureInfo {
pub image_handle: Option<Handle<Image>>,
pub is_cubemap: bool,
pub is_array: bool,
pub layer_count: u32,
pub face_count: u32,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum BrowserViewMode {
#[default]
Grid,
List,
}
#[derive(Resource)]
pub struct AssetBrowserState {
pub current_directory: PathBuf,
pub root_directory: PathBuf,
pub filter: String,
pub view_mode: BrowserViewMode,
pub needs_refresh: bool,
pub entries: Vec<DirEntry>,
pub selected_file: Option<String>,
pub last_click_time: f64,
}
impl Default for AssetBrowserState {
fn default() -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self {
current_directory: cwd.clone(),
root_directory: cwd,
filter: String::new(),
view_mode: BrowserViewMode::Grid,
needs_refresh: true,
entries: Vec::new(),
selected_file: None,
last_click_time: 0.0,
}
}
}
#[derive(Clone, Debug)]
pub struct DirEntry {
pub path: PathBuf,
pub file_name: String,
pub is_directory: bool,
pub texture_info: Option<TextureInfo>,
}
#[derive(Resource, Default)]
pub struct AssetPreviewState {
pub selected_path: Option<PathBuf>,
pub selected_info: Option<TextureInfo>,
pub current_layer: u32,
pub layer_images: Vec<Handle<Image>>,
}
#[derive(Resource, Default)]
pub struct ActiveTooltip(pub Option<Entity>);
#[derive(Event, Debug, Clone)]
struct SelectAssetPreview {
path: PathBuf,
info: TextureInfo,
}
#[derive(Component)]
pub struct AssetBrowserPanel;
#[derive(Component)]
pub struct AssetBrowserContent;
#[derive(Component)]
pub struct AssetBrowserBreadcrumb;
#[derive(Component)]
struct PreviewPanelContainer;
#[derive(Resource)]
struct AssetBrowserFolderTask(Task<Option<rfd::FileHandle>>);
fn is_image_file_path(path: &Path) -> bool {
let Some(ext) = path.extension() else {
return false;
};
let ext = ext.to_string_lossy().to_lowercase();
matches!(
ext.as_str(),
"png" | "jpg" | "jpeg" | "bmp" | "tga" | "webp" | "ktx2"
)
}
fn is_image_file(path: &str) -> bool {
is_image_file_path(Path::new(path))
}
fn read_ktx2_info(path: &Path) -> (u32, u32) {
let Ok(mut file) = std::fs::File::open(path) else {
return (1, 1);
};
use std::io::Read;
let mut header = [0u8; 40];
if file.read_exact(&mut header).is_err() {
return (1, 1);
}
let layer_count = u32::from_le_bytes([header[32], header[33], header[34], header[35]]);
let face_count = u32::from_le_bytes([header[36], header[37], header[38], header[39]]);
(layer_count, face_count)
}
fn setup_initial_directory(
mut state: ResMut<AssetBrowserState>,
mut commands: Commands,
project_root: Option<Res<crate::project::ProjectRoot>>,
) {
if let Some(project) = project_root {
let assets_dir = project.assets_dir();
state.root_directory = assets_dir.clone();
state.current_directory = assets_dir;
} else {
let assets_dir = state.root_directory.join("assets");
if assets_dir.is_dir() {
state.current_directory = assets_dir.clone();
state.root_directory = assets_dir;
}
}
state.needs_refresh = true;
setup_directory_watcher(&state.root_directory, &mut commands);
}
fn refresh_browser_on_change(
mut state: ResMut<AssetBrowserState>,
mut commands: Commands,
icon_font: Res<IconFont>,
asset_server: Res<AssetServer>,
content_query: Query<(Entity, Option<&Children>), With<AssetBrowserContent>>,
breadcrumb_query: Query<(Entity, Option<&Children>), With<AssetBrowserBreadcrumb>>,
) {
if !state.needs_refresh {
return;
}
state.needs_refresh = false;
state.entries.clear();
if let Ok(read_dir) = std::fs::read_dir(&state.current_directory) {
let mut entries: Vec<DirEntry> = read_dir
.filter_map(|entry| {
let entry = entry.ok()?;
let file_name = entry.file_name().to_string_lossy().to_string();
if file_name.starts_with('.') {
return None;
}
if !state.filter.is_empty()
&& !file_name
.to_lowercase()
.contains(&state.filter.to_lowercase())
{
return None;
}
let path = entry.path();
let is_directory = entry.file_type().ok()?.is_dir();
let texture_info = if !is_directory && is_image_file_path(&path) {
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
if ext == "ktx2" {
let (layer_count, face_count) = read_ktx2_info(&path);
let is_non_2d = layer_count > 1 || face_count > 1;
Some(TextureInfo {
image_handle: if is_non_2d {
None
} else {
load_thumbnail(&path, &asset_server)
},
is_cubemap: face_count > 1,
is_array: layer_count > 1,
layer_count,
face_count,
})
} else {
Some(TextureInfo {
image_handle: load_thumbnail(&path, &asset_server),
is_cubemap: false,
is_array: false,
layer_count: 1,
face_count: 1,
})
}
} else {
None
};
Some(DirEntry {
path,
file_name,
is_directory,
texture_info,
})
})
.collect();
entries.sort_by(|a, b| {
b.is_directory
.cmp(&a.is_directory)
.then_with(|| a.file_name.to_lowercase().cmp(&b.file_name.to_lowercase()))
});
state.entries = entries;
}
let Ok((content_entity, content_children)) = content_query.single() else {
return;
};
if let Some(children) = content_children {
for child in children.iter() {
commands.entity(child).despawn();
}
}
for entry in &state.entries {
let path_for_click = entry.path.to_string_lossy().to_string();
let is_dir = entry.is_directory;
if let Some(ref tex_info) = entry.texture_info {
let thumb_entity = commands
.spawn((
Node {
width: Val::Px(64.0),
height: Val::Px(80.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(2.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(4.0)),
..Default::default()
},
BorderColor::all(Color::NONE),
BackgroundColor(Color::NONE),
ChildOf(content_entity),
))
.id();
if let Some(ref img) = tex_info.image_handle {
commands.spawn((
ImageNode::new(img.clone()),
Node {
width: Val::Px(56.0),
height: Val::Px(56.0),
..Default::default()
},
ChildOf(thumb_entity),
));
} else {
let placeholder = commands
.spawn((
Node {
width: Val::Px(56.0),
height: Val::Px(56.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..Default::default()
},
BackgroundColor(Color::srgb(0.25, 0.25, 0.25)),
ChildOf(thumb_entity),
))
.id();
let badge_text = if tex_info.is_cubemap {
"Cubemap".to_string()
} else {
format!("{} layers", tex_info.layer_count)
};
commands.spawn((
Text::new(badge_text),
TextFont {
font_size: 8.0,
..Default::default()
},
TextColor(Color::srgb(0.8, 0.8, 0.8)),
ChildOf(placeholder),
));
}
let is_truncated = entry.file_name.len() > 10;
let display_name = if is_truncated {
format!("{}...", &entry.file_name[..8])
} else {
entry.file_name.clone()
};
let name_entity = commands
.spawn((
Text::new(display_name),
TextFont {
font_size: 9.0,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
max_width: Val::Px(60.0),
overflow: Overflow::clip(),
..Default::default()
},
ChildOf(thumb_entity),
))
.id();
if is_truncated {
attach_tooltip(&mut commands, name_entity, entry.file_name.clone());
}
commands.entity(thumb_entity).observe(
|hover: On<Pointer<Over>>, mut borders: Query<&mut BorderColor>| {
if let Ok(mut border) = borders.get_mut(hover.event_target()) {
*border = BorderColor::all(tokens::SELECTED_BORDER);
}
},
);
commands.entity(thumb_entity).observe(
|out: On<Pointer<Out>>, mut borders: Query<&mut BorderColor>| {
if let Ok(mut border) = borders.get_mut(out.event_target()) {
*border = BorderColor::all(Color::NONE);
}
},
);
let tex_info_clone = tex_info.clone();
let entry_path = entry.path.clone();
let click_path = path_for_click.clone();
commands.entity(thumb_entity).observe(
move |_: On<Pointer<Click>>, mut commands: Commands| {
if tex_info_clone.is_cubemap || tex_info_clone.is_array {
commands.trigger(SelectAssetPreview {
path: entry_path.clone(),
info: tex_info_clone.clone(),
});
} else {
commands.trigger(ApplyTextureToFaces {
path: click_path.clone(),
});
}
},
);
} else {
let item = FileBrowserItem {
path: entry.path.to_string_lossy().to_string(),
is_directory: entry.is_directory,
file_name: entry.file_name.clone(),
};
let item_entity = match state.view_mode {
BrowserViewMode::Grid => commands
.spawn((
file_browser::file_browser_item(&item, &icon_font),
ChildOf(content_entity),
))
.id(),
BrowserViewMode::List => commands
.spawn((
file_browser::file_browser_list_item(&item, &icon_font),
ChildOf(content_entity),
))
.id(),
};
let is_selected =
state.selected_file.as_deref() == Some(entry.path.to_string_lossy().as_ref());
if is_selected {
commands
.entity(item_entity)
.insert(BackgroundColor(tokens::ELEVATED_BG));
}
commands
.entity(item_entity)
.observe(highlight_on_hover)
.observe(unhighlight_on_out)
.observe(
move |_: On<Pointer<Click>>,
mut state: ResMut<AssetBrowserState>,
time: Res<Time>| {
let now = time.elapsed_secs_f64();
let is_double = state.selected_file.as_deref() == Some(&path_for_click)
&& (now - state.last_click_time) < 0.4;
if is_double && is_dir {
state.current_directory = PathBuf::from(&path_for_click);
state.selected_file = None;
state.needs_refresh = true;
} else if is_double && !is_dir {
} else {
state.selected_file = Some(path_for_click.clone());
state.last_click_time = now;
state.needs_refresh = true;
}
},
);
}
}
let Ok((breadcrumb_entity, breadcrumb_children)) = breadcrumb_query.single() else {
return;
};
if let Some(children) = breadcrumb_children {
for child in children.iter() {
commands.entity(child).despawn();
}
}
let current_dir = state.current_directory.to_string_lossy().to_string();
commands
.spawn((
Node {
width: percent(100),
align_items: AlignItems::Center,
justify_content: JustifyContent::FlexStart,
column_gap: Val::Px(2.0),
..default()
},
ChildOf(breadcrumb_entity),
))
.with_children(|parent| {
let components: Vec<&str> = current_dir
.split(std::path::MAIN_SEPARATOR)
.filter(|s| !s.is_empty())
.collect();
let mut cumulative = String::new();
for (i, component) in components.iter().enumerate() {
cumulative += std::path::MAIN_SEPARATOR_STR;
cumulative += component;
let nav_path = cumulative.clone();
if i > 0 {
parent.spawn((
Text::new(" / "),
TextFont {
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
));
}
parent
.spawn((
Button,
Text::new(*component),
Node {
border_radius: BorderRadius::all(Val::Px(3.0)),
padding: UiRect::axes(Val::Px(2.0), Val::Px(1.0)),
..default()
},
TextFont {
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_TERTIARY),
))
.observe(move |_: On<Pointer<Click>>, mut commands: Commands| {
commands.trigger(FileItemDoubleClicked {
path: nav_path.clone(),
is_directory: true,
});
})
.observe(highlight_on_hover)
.observe(unhighlight_on_out);
}
if let Some(ref selected) = state.selected_file {
let file_name = Path::new(selected)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
if !file_name.is_empty() {
parent.spawn((
Text::new(" / "),
TextFont {
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
));
parent.spawn((
Text::new(file_name),
TextFont {
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
));
}
}
});
}
fn highlight_on_hover(hover: On<Pointer<Over>>, mut bg: Query<&mut BackgroundColor>) {
if let Ok(mut bg) = bg.get_mut(hover.event_target()) {
bg.0 = tokens::HOVER_BG;
}
}
fn unhighlight_on_out(out: On<Pointer<Out>>, mut bg: Query<&mut BackgroundColor>) {
if let Ok(mut bg) = bg.get_mut(out.event_target()) {
bg.0 = Color::NONE;
}
}
fn load_thumbnail(path: &Path, asset_server: &AssetServer) -> Option<Handle<Image>> {
let abs = path.to_string_lossy().replace('\\', "/");
Some(asset_server.load(abs))
}
fn remove_incompatible_image_nodes(
mut commands: Commands,
image_nodes: Query<(Entity, &ImageNode)>,
images: Res<Assets<Image>>,
) {
use bevy::render::render_resource::TextureSampleType;
for (entity, image_node) in &image_nodes {
if let Some(image) = images.get(&image_node.image) {
let sample = image.texture_descriptor.format.sample_type(None, None);
if !matches!(sample, Some(TextureSampleType::Float { .. })) {
commands.entity(entity).remove::<ImageNode>();
}
}
}
}
fn handle_file_double_click(
event: On<FileItemDoubleClicked>,
mut state: ResMut<AssetBrowserState>,
mut commands: Commands,
) {
if event.is_directory {
state.current_directory = PathBuf::from(&event.path);
state.selected_file = None; state.needs_refresh = true;
return;
}
if is_image_file(&event.path) {
let p = Path::new(&event.path);
let is_non_2d = p
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("ktx2"))
&& is_ktx2_non_2d(p);
if !is_non_2d {
commands.trigger(ApplyTextureToFaces {
path: event.path.clone(),
});
}
}
}
fn try_find_registry_material(
path: &str,
registry: &MaterialRegistry,
) -> Option<Handle<StandardMaterial>> {
let re = pbr_filename_regex()?;
let filename = Path::new(path).file_name()?.to_str()?;
let caps = re.captures(filename)?;
let base_name = caps.get(1)?.as_str().to_lowercase();
registry.get_by_name(&base_name).map(|e| e.handle.clone())
}
fn handle_apply_texture(
event: On<ApplyTextureToFaces>,
brush_selection: Res<BrushSelection>,
edit_mode: Res<EditMode>,
selection: Res<Selection>,
mut brushes: Query<&mut Brush>,
mut history: ResMut<CommandHistory>,
mut last_material: ResMut<LastUsedMaterial>,
asset_server: Res<AssetServer>,
mut materials: ResMut<Assets<StandardMaterial>>,
registry: Res<MaterialRegistry>,
brush_groups: Query<(), With<jackdaw_jsn::types::BrushGroup>>,
children_query: Query<&Children>,
mut commands: Commands,
) {
let material = if let Some(handle) = try_find_registry_material(&event.path, ®istry) {
handle
} else {
let image: Handle<Image> = asset_server.load(event.path.clone());
materials.add(StandardMaterial {
base_color_texture: Some(image),
..default()
})
};
if *edit_mode == EditMode::BrushEdit(BrushEditMode::Face) && !brush_selection.faces.is_empty() {
if let Some(entity) = brush_selection.entity {
if let Ok(mut brush) = brushes.get_mut(entity) {
let old = brush.clone();
for &face_idx in &brush_selection.faces {
if face_idx < brush.faces.len() {
brush.faces[face_idx].material = material.clone();
}
}
let cmd = SetBrush {
entity,
old,
new: brush.clone(),
label: "Apply texture".into(),
};
history.undo_stack.push(Box::new(cmd));
history.redo_stack.clear();
commands
.entity(entity)
.insert(crate::inspector::InspectorDirty);
}
}
} else {
let targets: Vec<Entity> = selection
.entities
.iter()
.flat_map(|&e| {
if brush_groups.contains(e) {
children_query
.get(e)
.map(|c| c.iter().collect::<Vec<_>>())
.unwrap_or_default()
} else {
vec![e]
}
})
.collect();
for entity in targets {
if let Ok(mut brush) = brushes.get_mut(entity) {
let old = brush.clone();
for face in brush.faces.iter_mut() {
face.material = material.clone();
}
let cmd = SetBrush {
entity,
old,
new: brush.clone(),
label: "Apply texture".into(),
};
history.undo_stack.push(Box::new(cmd));
history.redo_stack.clear();
commands
.entity(entity)
.insert(crate::inspector::InspectorDirty);
}
}
}
last_material.material = Some(material);
}
fn handle_select_asset_preview(
event: On<SelectAssetPreview>,
mut preview_state: ResMut<AssetPreviewState>,
) {
if preview_state.selected_path.as_ref() == Some(&event.path) {
preview_state.selected_path = None;
preview_state.selected_info = None;
preview_state.layer_images.clear();
preview_state.current_layer = 0;
} else {
preview_state.selected_path = Some(event.path.clone());
preview_state.selected_info = Some(event.info.clone());
preview_state.current_layer = 0;
preview_state.layer_images.clear();
}
}
pub fn attach_tooltip(commands: &mut Commands, entity: Entity, text: String) {
commands.entity(entity).observe(
move |trigger: On<Pointer<Over>>,
mut commands: Commands,
mut tooltip: ResMut<ActiveTooltip>| {
if let Some(old) = tooltip.0.take() {
commands.entity(old).try_despawn();
}
let anchor = trigger.event_target();
let tip = commands
.spawn(popover::popover(
popover::PopoverProps::new(anchor)
.with_placement(popover::PopoverPlacement::Bottom)
.with_padding(4.0)
.with_z_index(300),
))
.id();
commands.spawn((
Text::new(text.clone()),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
ChildOf(tip),
));
tooltip.0 = Some(tip);
},
);
commands.entity(entity).observe(
|_: On<Pointer<Out>>, mut commands: Commands, mut tooltip: ResMut<ActiveTooltip>| {
if let Some(old) = tooltip.0.take() {
commands.entity(old).try_despawn();
}
},
);
}
fn extract_array_layers(
mut preview_state: ResMut<AssetPreviewState>,
mut images: ResMut<Assets<Image>>,
) {
let dominated = preview_state
.selected_info
.as_ref()
.is_some_and(|i| i.is_array)
&& preview_state.layer_images.is_empty()
&& preview_state.selected_path.is_some();
if !dominated {
return;
}
let path = preview_state.selected_path.as_ref().unwrap();
let Ok(bytes) = std::fs::read(path) else {
return;
};
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("ktx2");
let Ok(image) = Image::from_buffer(
&bytes,
ImageType::Extension(ext),
CompressedImageFormats::all(),
true,
ImageSampler::default(),
RenderAssetUsages::default(),
) else {
return;
};
let sample = image.texture_descriptor.format.sample_type(None, None);
if !matches!(sample, Some(TextureSampleType::Float { .. })) {
return;
}
let layer_count = preview_state.selected_info.as_ref().unwrap().layer_count;
let Some(ref data) = image.data else {
return;
};
let total_size = data.len();
let layer_size = total_size / layer_count as usize;
if layer_size == 0 || total_size % layer_count as usize != 0 {
return;
}
let desc = &image.texture_descriptor;
for i in 0..layer_count {
let start = i as usize * layer_size;
let end = start + layer_size;
let mut layer_img = Image::new(
Extent3d {
width: desc.size.width,
height: desc.size.height,
depth_or_array_layers: 1,
},
TextureDimension::D2,
data[start..end].to_vec(),
desc.format,
image.asset_usage,
);
layer_img.sampler = image.sampler.clone();
preview_state.layer_images.push(images.add(layer_img));
}
}
fn update_preview_panel(
mut commands: Commands,
preview_state: Res<AssetPreviewState>,
container_query: Query<(Entity, Option<&Children>), With<PreviewPanelContainer>>,
) {
if !preview_state.is_changed() {
return;
}
let Ok((container, children)) = container_query.single() else {
return;
};
if let Some(children) = children {
for child in children.iter() {
commands.entity(child).despawn();
}
}
let Some(ref path) = preview_state.selected_path else {
return;
};
let Some(ref info) = preview_state.selected_info else {
return;
};
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if let Some(ref img) = info.image_handle {
commands.spawn((
ImageNode::new(img.clone()),
Node {
width: Val::Px(128.0),
height: Val::Px(128.0),
align_self: AlignSelf::Center,
..Default::default()
},
ChildOf(container),
));
} else if !preview_state.layer_images.is_empty() {
let idx = (preview_state.current_layer as usize).min(preview_state.layer_images.len() - 1);
commands.spawn((
ImageNode::new(preview_state.layer_images[idx].clone()),
Node {
width: Val::Px(128.0),
height: Val::Px(128.0),
align_self: AlignSelf::Center,
..Default::default()
},
ChildOf(container),
));
}
commands.spawn((
Text::new(file_name),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
Node {
align_self: AlignSelf::Center,
margin: UiRect::vertical(Val::Px(tokens::SPACING_XS)),
..Default::default()
},
ChildOf(container),
));
let type_text = if info.is_cubemap {
format!("Cubemap ({} faces)", info.face_count)
} else if info.is_array {
format!("{} layers", info.layer_count)
} else {
"2D Texture".to_string()
};
commands.spawn((
Text::new(type_text),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
align_self: AlignSelf::Center,
..Default::default()
},
ChildOf(container),
));
if info.is_array && !preview_state.layer_images.is_empty() {
let layer_text = format!(
"Layer {} of {}",
preview_state.current_layer + 1,
info.layer_count
);
let nav_row = commands
.spawn((
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
align_self: AlignSelf::Center,
column_gap: Val::Px(tokens::SPACING_SM),
margin: UiRect::top(Val::Px(tokens::SPACING_XS)),
..Default::default()
},
ChildOf(container),
))
.id();
let prev_btn = commands
.spawn((
Node {
padding: UiRect::axes(Val::Px(tokens::SPACING_SM), Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(3.0)),
..Default::default()
},
BackgroundColor(tokens::INPUT_BG),
ChildOf(nav_row),
))
.id();
commands.spawn((
Text::new("<"),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
ChildOf(prev_btn),
));
let layer_count = info.layer_count;
commands.entity(prev_btn).observe(
move |_: On<Pointer<Click>>, mut ps: ResMut<AssetPreviewState>| {
if ps.current_layer > 0 {
ps.current_layer -= 1;
} else {
ps.current_layer = layer_count.saturating_sub(1);
}
},
);
commands.spawn((
Text::new(layer_text),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
ChildOf(nav_row),
));
let next_btn = commands
.spawn((
Node {
padding: UiRect::axes(Val::Px(tokens::SPACING_SM), Val::Px(2.0)),
border_radius: BorderRadius::all(Val::Px(3.0)),
..Default::default()
},
BackgroundColor(tokens::INPUT_BG),
ChildOf(nav_row),
))
.id();
commands.spawn((
Text::new(">"),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
ChildOf(next_btn),
));
let layer_count2 = info.layer_count;
commands.entity(next_btn).observe(
move |_: On<Pointer<Click>>, mut ps: ResMut<AssetPreviewState>| {
ps.current_layer = (ps.current_layer + 1) % layer_count2;
},
);
}
if !info.is_cubemap && !info.is_array {
let path_str = path.to_string_lossy().to_string();
let apply_btn = commands
.spawn((
Node {
padding: UiRect::axes(Val::Px(tokens::SPACING_MD), Val::Px(tokens::SPACING_XS)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_SM)),
align_self: AlignSelf::Center,
margin: UiRect::top(Val::Px(tokens::SPACING_XS)),
..Default::default()
},
BackgroundColor(tokens::INPUT_BG),
ChildOf(container),
))
.id();
commands.spawn((
Text::new("Apply"),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
ChildOf(apply_btn),
));
commands
.entity(apply_btn)
.observe(move |_: On<Pointer<Click>>, mut commands: Commands| {
commands.trigger(ApplyTextureToFaces {
path: path_str.clone(),
});
});
commands.entity(apply_btn).observe(
|hover: On<Pointer<Over>>, mut bg: Query<&mut BackgroundColor>| {
if let Ok(mut bg) = bg.get_mut(hover.event_target()) {
bg.0 = tokens::HOVER_BG;
}
},
);
commands.entity(apply_btn).observe(
|out: On<Pointer<Out>>, mut bg: Query<&mut BackgroundColor>| {
if let Ok(mut bg) = bg.get_mut(out.event_target()) {
bg.0 = tokens::INPUT_BG;
}
},
);
}
}
fn spawn_asset_folder_dialog(
_: On<Pointer<Click>>,
mut commands: Commands,
raw_handle: Query<&RawHandleWrapper, With<PrimaryWindow>>,
) {
let mut dialog = AsyncFileDialog::new().set_title("Select assets directory");
if let Ok(rh) = raw_handle.single() {
let handle = unsafe { rh.get_handle() };
dialog = dialog.set_parent(&handle);
}
let task = AsyncComputeTaskPool::get().spawn(async move { dialog.pick_folder().await });
commands.insert_resource(AssetBrowserFolderTask(task));
}
fn poll_asset_browser_folder(world: &mut World) {
let Some(mut task_res) = world.get_resource_mut::<AssetBrowserFolderTask>() else {
return;
};
let Some(result) = future::block_on(future::poll_once(&mut task_res.0)) else {
return;
};
world.remove_resource::<AssetBrowserFolderTask>();
if let Some(file_handle) = result {
let path = file_handle.path().to_path_buf();
let mut state = world.resource_mut::<AssetBrowserState>();
state.root_directory = path.clone();
state.current_directory = path.clone();
state.needs_refresh = true;
let mut commands = world.commands();
setup_directory_watcher(&path, &mut commands);
}
}
pub fn asset_browser_panel(icon_font: Handle<Font>) -> impl Bundle {
let folder_icon_font = icon_font.clone();
let _ = icon_font;
(
AssetBrowserPanel,
EditorEntity,
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_LG)),
overflow: Overflow::clip(),
..Default::default()
},
BackgroundColor(tokens::PANEL_BG),
children![
(
Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
min_width: Val::Px(0.0),
padding: UiRect::all(Val::Px(tokens::SPACING_SM)),
..Default::default()
},
children![
(
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
width: Val::Percent(100.0),
height: Val::Px(34.0),
padding: UiRect::axes(
Val::Px(tokens::SPACING_MD),
Val::Px(tokens::SPACING_SM)
),
flex_shrink: 0.0,
..Default::default()
},
children![
(
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
overflow: Overflow::clip(),
flex_shrink: 1.0,
flex_grow: 1.0,
..Default::default()
},
children![(
AssetBrowserBreadcrumb,
EditorEntity,
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
..Default::default()
},
),],
),
(
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: Val::Px(tokens::SPACING_SM),
flex_shrink: 0.0,
..Default::default()
},
children![
(
Node {
width: Val::Px(200.0),
..Default::default()
},
children![(jackdaw_feathers::text_edit::text_edit(
jackdaw_feathers::text_edit::TextEditProps::default()
.with_placeholder("Search...")
.allow_empty()
),)],
),
asset_folder_button(folder_icon_font),
],
),
],
),
(
EditorEntity,
Node {
flex_direction: FlexDirection::Row,
width: Val::Percent(100.0),
flex_grow: 1.0,
min_height: Val::Px(0.0),
border: UiRect::top(Val::Px(1.0)),
..Default::default()
},
BorderColor::all(tokens::BORDER_SUBTLE),
children![
(
AssetBrowserContent,
EditorEntity,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
align_content: AlignContent::FlexStart,
flex_grow: 1.0,
min_width: Val::Px(0.0),
min_height: Val::Px(0.0),
overflow: Overflow::scroll_y(),
padding: UiRect::all(Val::Px(tokens::SPACING_SM)),
row_gap: Val::Px(tokens::SPACING_XS),
column_gap: Val::Px(tokens::SPACING_XS),
..Default::default()
},
),
(
PreviewPanelContainer,
EditorEntity,
Node {
flex_direction: FlexDirection::Column,
width: Val::Px(160.0),
flex_shrink: 0.0,
padding: UiRect::all(Val::Px(tokens::SPACING_SM)),
border: UiRect::left(Val::Px(1.0)),
overflow: Overflow::scroll_y(),
..Default::default()
},
BorderColor::all(tokens::PANEL_HEADER_BG),
),
],
)
],
), ],
)
}
fn asset_folder_button(icon_font: Handle<Font>) -> impl Bundle {
(
Node {
padding: UiRect::all(Val::Px(tokens::SPACING_XS)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_SM)),
..Default::default()
},
icons::icon_colored(
icons::Icon::FolderOpen,
tokens::FONT_MD,
icon_font,
tokens::TEXT_SECONDARY,
),
observe(spawn_asset_folder_dialog),
)
}
fn check_watcher_events(
watcher: Option<Res<DirectoryWatcher>>,
mut browser: ResMut<AssetBrowserState>,
mut material_browser: ResMut<crate::material_browser::MaterialBrowserState>,
) {
let Some(watcher) = watcher else { return };
let Ok(rx) = watcher.receiver.lock() else {
return;
};
let mut changed = false;
while rx.try_recv().is_ok() {
changed = true;
}
if changed {
browser.needs_refresh = true;
material_browser.needs_rescan = true;
}
}