use std::path::PathBuf;
use crate::EditorEntity;
use bevy::{picking::hover::Hovered, prelude::*, ui_widgets::observe};
use jackdaw_feathers::{
icons::Icon,
text_edit::{self, TextEditProps, TextEditValue},
tokens,
};
pub struct PrefabPickerPlugin;
impl Plugin for PrefabPickerPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
Update,
(filter_prefab_picker, close_prefab_picker_on_dismiss)
.run_if(in_state(crate::AppState::Editor)),
);
}
}
#[derive(Component)]
struct PrefabPicker;
#[derive(Component)]
struct PrefabPickerSearch;
#[derive(Component)]
struct PrefabPickerEntry {
#[allow(dead_code)]
path: String,
display_name: String,
}
pub fn open_prefab_picker(world: &mut World) {
let existing: Vec<Entity> = world
.query_filtered::<Entity, With<PrefabPicker>>()
.iter(world)
.collect();
if !existing.is_empty() {
for e in existing {
if let Ok(ec) = world.get_entity_mut(e) {
ec.despawn();
}
}
return;
}
let assets_dir = world
.get_resource::<crate::project::ProjectRoot>()
.map(|p| p.assets_dir())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default().join("assets"));
let mut prefabs: Vec<(String, String)> = Vec::new(); scan_jsn_files(&assets_dir, &assets_dir, &mut prefabs);
prefabs.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
let viewport_entity = world
.query_filtered::<Entity, With<crate::viewport::SceneViewport>>()
.iter(world)
.next();
let Some(parent_entity) = viewport_entity else {
warn!("No viewport found for prefab picker");
return;
};
let icon_font = world
.resource::<jackdaw_feathers::icons::IconFont>()
.0
.clone();
let mut commands = world.commands();
let picker = commands
.spawn((
PrefabPicker,
crate::BlocksCameraInput,
EditorEntity,
Hovered::default(),
Node {
position_type: PositionType::Absolute,
flex_direction: FlexDirection::Column,
width: Val::Px(420.0),
max_height: Val::Px(600.0),
top: Val::Px(40.0),
left: Val::Percent(50.0),
margin: UiRect::left(Val::Px(-210.0)),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(tokens::BORDER_RADIUS_MD)),
..Default::default()
},
BackgroundColor(tokens::PANEL_BG),
BorderColor::all(tokens::BORDER_SUBTLE),
GlobalZIndex(100),
ChildOf(parent_entity),
))
.id();
commands.spawn((
PrefabPickerSearch,
text_edit::text_edit(
TextEditProps::default()
.with_placeholder("Search prefabs...")
.allow_empty(),
),
ChildOf(picker),
));
let list = commands
.spawn((
Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
overflow: Overflow::scroll_y(),
..Default::default()
},
ChildOf(picker),
))
.id();
if prefabs.is_empty() {
commands.spawn((
Text::new("No .jsn files found"),
TextFont {
font_size: tokens::FONT_SM,
..Default::default()
},
TextColor(tokens::TEXT_SECONDARY),
Node {
padding: UiRect::all(Val::Px(tokens::SPACING_MD)),
..Default::default()
},
ChildOf(list),
));
}
for (path, display_name) in &prefabs {
let entry_path = path.clone();
let entry_display = display_name.clone();
let icon_font_clone = icon_font.clone();
let entry_id = commands
.spawn((
PrefabPickerEntry {
path: entry_path.clone(),
display_name: entry_display.clone(),
},
Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(tokens::SPACING_LG), Val::Px(tokens::SPACING_SM)),
column_gap: Val::Px(tokens::SPACING_SM),
width: Val::Percent(100.0),
..Default::default()
},
BackgroundColor(Color::NONE),
ChildOf(list),
observe(move |_: On<Pointer<Click>>, mut commands: Commands| {
let path = entry_path.clone();
commands.queue(move |world: &mut World| {
let pickers: Vec<Entity> = world
.query_filtered::<Entity, With<PrefabPicker>>()
.iter(world)
.collect();
for e in pickers {
if let Ok(ec) = world.get_entity_mut(e) {
ec.despawn();
}
}
crate::entity_templates::instantiate_jsn_prefab(world, &path, Vec3::ZERO);
});
}),
observe(
move |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;
}
},
),
observe(
move |out: On<Pointer<Out>>, mut bg: Query<&mut BackgroundColor>| {
if let Ok(mut bg) = bg.get_mut(out.event_target()) {
bg.0 = Color::NONE;
}
},
),
))
.id();
commands.spawn((
Text::new(String::from(Icon::Blocks.unicode())),
TextFont {
font: icon_font_clone,
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::FILE_ICON_COLOR),
ChildOf(entry_id),
));
commands.spawn((
Text::new(entry_display),
TextFont {
font_size: tokens::FONT_MD,
..Default::default()
},
TextColor(tokens::TEXT_PRIMARY),
ChildOf(entry_id),
));
}
world.flush();
}
fn scan_jsn_files(dir: &PathBuf, _assets_root: &PathBuf, results: &mut Vec<(String, String)>) {
let Ok(entries) = std::fs::read_dir(dir) else {
warn!("Prefab picker: failed to read directory {:?}", dir);
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
scan_jsn_files(&path, _assets_root, results);
} else if path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("jsn"))
{
if path
.file_name()
.is_some_and(|n| n.eq_ignore_ascii_case("project.jsn"))
{
continue;
}
let path_str = path.to_string_lossy().to_string();
let display_name = std::fs::read_to_string(&path)
.ok()
.and_then(|json| serde_json::from_str::<serde_json::Value>(&json).ok())
.and_then(|v| {
v.get("metadata")?
.get("name")?
.as_str()
.map(|s| s.to_string())
})
.filter(|name| !name.is_empty() && name != "Untitled")
.unwrap_or_else(|| {
path.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "Unknown".to_string())
});
info!("Prefab picker: found {:?} -> {:?}", path_str, display_name);
results.push((path_str, display_name));
}
}
}
fn close_prefab_picker_on_dismiss(
keyboard: Res<ButtonInput<KeyCode>>,
mouse: Res<ButtonInput<MouseButton>>,
picker: Query<(Entity, &Hovered), With<PrefabPicker>>,
mut commands: Commands,
) {
let Ok((entity, hovered)) = picker.single() else {
return;
};
let esc = keyboard.just_pressed(KeyCode::Escape);
let clicked_outside = mouse.get_just_pressed().next().is_some() && !hovered.get();
if esc || clicked_outside {
commands.entity(entity).despawn();
}
}
fn filter_prefab_picker(
search_query: Query<&TextEditValue, (With<PrefabPickerSearch>, Changed<TextEditValue>)>,
mut entries: Query<(Entity, &PrefabPickerEntry, &mut Node), Without<PrefabPickerSearch>>,
) {
let Ok(search) = search_query.single() else {
return;
};
let filter = search.0.trim().to_lowercase();
for (_entity, entry, mut node) in &mut entries {
let matches = filter.is_empty() || entry.display_name.to_lowercase().contains(&filter);
node.display = if matches {
Display::Flex
} else {
Display::None
};
}
}