use crate::ecs::EditorWorld;
use crate::systems::polyhaven::Category;
use crate::systems::retained_ui::{Action, ThumbnailCard, UiHandles};
use nightshade::prelude::*;
const KHRONOS_RECT: Rect = Rect {
min: Vec2::new(80.0, 80.0),
max: Vec2::new(560.0, 540.0),
};
const POLYHAVEN_RECT: Rect = Rect {
min: Vec2::new(120.0, 120.0),
max: Vec2::new(600.0, 580.0),
};
#[cfg(not(target_arch = "wasm32"))]
const SKETCHFAB_RECT: Rect = Rect {
min: Vec2::new(160.0, 160.0),
max: Vec2::new(540.0, 380.0),
};
#[cfg(not(target_arch = "wasm32"))]
const KENNEY_RECT: Rect = Rect {
min: Vec2::new(140.0, 100.0),
max: Vec2::new(720.0, 620.0),
};
#[derive(Default, Clone)]
pub struct KhronosHandles {
pub panel: Entity,
pub status_label: Entity,
pub grid_root: Entity,
pub grid: Entity,
pub filter_input: Entity,
pub filter_last: String,
pub signature: u64,
pub cards: Vec<ThumbnailCard>,
pub lookup: std::collections::HashMap<Entity, usize>,
}
#[derive(Default, Clone)]
pub struct PolyhavenHandles {
pub panel: Entity,
pub tab_bar: Entity,
pub filter_input: Entity,
pub status: Entity,
pub list_root: Entity,
pub last_tab: usize,
pub last_filter: String,
pub hdris_signature: u64,
pub models_signature: u64,
pub list_items: Vec<Entity>,
pub hdri_lookup: std::collections::HashMap<Entity, usize>,
pub model_lookup: std::collections::HashMap<Entity, usize>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default, Clone)]
pub struct SketchfabHandles {
pub panel: Entity,
pub token_input: Entity,
pub url_input: Entity,
pub fetch_button: Entity,
pub status_label: Entity,
pub token_link: Entity,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default, Clone)]
pub struct KenneyPackSection {
pub header: Entity,
pub content: Entity,
pub entry_indices: Vec<usize>,
pub built: bool,
pub last_open: bool,
pub cards: Vec<ThumbnailCard>,
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Default, Clone)]
pub struct KenneyHandles {
pub panel: Entity,
pub status_label: Entity,
pub root_label: Entity,
pub pick_directory_button: Entity,
pub pick_zip_button: Entity,
pub filter_input: Entity,
pub tab_bar: Entity,
pub all_view: Entity,
pub all_grid_root: Entity,
pub packs_view: Entity,
pub packs_root: Entity,
pub filter_last: String,
pub root_signature: String,
pub entries_signature: u64,
pub last_tab: usize,
pub last_status_text: String,
pub last_root_text: String,
pub cards: Vec<ThumbnailCard>,
pub lookup: std::collections::HashMap<Entity, usize>,
pub pack_sections: Vec<KenneyPackSection>,
}
#[derive(Default, Clone)]
pub struct BrowsersHandles {
pub khronos: KhronosHandles,
pub polyhaven: PolyhavenHandles,
#[cfg(not(target_arch = "wasm32"))]
pub sketchfab: SketchfabHandles,
#[cfg(not(target_arch = "wasm32"))]
pub kenney: KenneyHandles,
}
pub fn build(tree: &mut UiTreeBuilder) -> BrowsersHandles {
let mut handles = BrowsersHandles {
khronos: build_khronos(tree),
polyhaven: build_polyhaven(tree),
#[cfg(not(target_arch = "wasm32"))]
sketchfab: build_sketchfab(tree),
#[cfg(not(target_arch = "wasm32"))]
kenney: build_kenney(tree),
};
handles.khronos.signature = u64::MAX;
handles.polyhaven.last_tab = usize::MAX;
handles.polyhaven.hdris_signature = u64::MAX;
handles.polyhaven.models_signature = u64::MAX;
#[cfg(not(target_arch = "wasm32"))]
{
handles.kenney.entries_signature = u64::MAX;
handles.kenney.last_tab = usize::MAX;
}
handles
}
fn build_khronos(tree: &mut UiTreeBuilder) -> KhronosHandles {
let panel = tree.add_floating_panel("khronos_browser", "Khronos sample assets", KHRONOS_RECT);
ui_set_visible(tree.world_mut(), panel, false);
let content = super::panel_content(tree, panel);
let mut filter_input = Entity::default();
let mut status_label = Entity::default();
let mut grid_root = Entity::default();
tree.in_parent(content, |tree| {
filter_input = tree.add_text_input("Search assets");
status_label = browser_status_label(tree, "Loading index...");
grid_root = browser_grid_root(tree);
});
KhronosHandles {
panel,
status_label,
grid_root,
filter_input,
..Default::default()
}
}
fn browser_grid_root(tree: &mut UiTreeBuilder) -> Entity {
let wrapper = tree.add_node().fill_width().flex_grow(1.0).entity();
let scroll = tree.in_parent(wrapper, |tree| tree.add_scroll_area_fill(8.0, 8.0));
widget::<UiScrollAreaData>(tree.world_mut(), scroll)
.map(|d| d.content_entity)
.unwrap_or(scroll)
}
fn build_polyhaven(tree: &mut UiTreeBuilder) -> PolyhavenHandles {
let panel = tree.add_floating_panel("polyhaven_browser", "Polyhaven", POLYHAVEN_RECT);
ui_set_visible(tree.world_mut(), panel, false);
let content = super::panel_content(tree, panel);
let mut tab_bar = Entity::default();
let mut filter_input = Entity::default();
let mut status = Entity::default();
let mut list_root = Entity::default();
tree.in_parent(content, |tree| {
tab_bar = tree.add_tab_bar(&["HDRIs", "Models"], 0);
filter_input = tree.add_text_input("Search Polyhaven");
status = browser_status_label(tree, "Loading index...");
let wrapper = tree.add_node().fill_width().flex_grow(1.0).entity();
let scroll = tree.in_parent(wrapper, |tree| tree.add_scroll_area_fill(4.0, 2.0));
list_root = widget::<UiScrollAreaData>(tree.world_mut(), scroll)
.map(|d| d.content_entity)
.unwrap_or(scroll);
});
PolyhavenHandles {
panel,
tab_bar,
filter_input,
status,
list_root,
..Default::default()
}
}
#[cfg(not(target_arch = "wasm32"))]
fn build_sketchfab(tree: &mut UiTreeBuilder) -> SketchfabHandles {
let panel = tree.add_floating_panel("sketchfab_browser", "Sketchfab", SKETCHFAB_RECT);
ui_set_visible(tree.world_mut(), panel, false);
let content = super::panel_content(tree, panel);
let theme = tree
.world_mut()
.resources
.retained_ui
.theme_state
.active_theme();
let font = theme.font_size;
let text_color = theme.text_color;
let dim = vec4(0.7, 0.7, 0.75, 1.0);
let mut token_input = Entity::default();
let mut url_input = Entity::default();
let mut fetch_button = Entity::default();
let mut status_label = Entity::default();
let mut token_link = Entity::default();
tree.in_parent(content, |tree| {
tree.add_node()
.size(100.pct(), (18.0).px())
.with_text("Sketchfab API token", font * 0.85)
.text_left()
.color_raw::<UiBase>(dim)
.entity();
token_input = tree.add_text_input("paste your API token");
ui_text_input_set_password(tree.world_mut(), token_input, true);
let link_color = vec4(0.49, 0.71, 0.96, 1.0);
let link_hover = vec4(0.66, 0.84, 1.0, 1.0);
token_link = tree
.add_node()
.size(100.pct(), (18.0).px())
.with_text("Get a token at sketchfab.com/settings/password", font * 0.8)
.text_left()
.color_raw::<UiBase>(link_color)
.color_raw::<UiHover>(link_hover)
.with_interaction()
.with_cursor_icon(winit::window::CursorIcon::Pointer)
.entity();
tree.add_node()
.size(100.pct(), (18.0).px())
.with_text("Model URL", font * 0.85)
.text_left()
.color_raw::<UiBase>(dim)
.entity();
url_input = tree.add_text_input("https://sketchfab.com/3d-models/...");
fetch_button = tree.add_button("Fetch");
status_label = tree
.add_node()
.size(100.pct(), (18.0).px())
.with_text("idle", font * 0.85)
.text_left()
.color_raw::<UiBase>(text_color)
.entity();
});
SketchfabHandles {
panel,
token_input,
url_input,
fetch_button,
status_label,
token_link,
}
}
#[cfg(not(target_arch = "wasm32"))]
fn build_kenney(tree: &mut UiTreeBuilder) -> KenneyHandles {
let panel = tree.add_floating_panel("kenney_browser", "Kenney assets", KENNEY_RECT);
ui_set_visible(tree.world_mut(), panel, false);
let content = super::panel_content(tree, panel);
let mut pick_directory_button = Entity::default();
let mut pick_zip_button = Entity::default();
let mut root_label = Entity::default();
let mut filter_input = Entity::default();
let mut status_label = Entity::default();
let mut tab_bar = Entity::default();
let mut all_view = Entity::default();
let mut all_grid_root = Entity::default();
let mut packs_view = Entity::default();
let mut packs_root = Entity::default();
tree.in_parent(content, |tree| {
let row = tree
.add_node()
.fill_width()
.auto_size(AutoSizeMode::Height)
.flow(FlowDirection::Horizontal, 0.0, 8.0)
.entity();
tree.in_parent(row, |tree| {
pick_directory_button = tree.add_button("Pick directory");
pick_zip_button = tree.add_button("Pick .zip");
});
root_label = browser_status_label(tree, "No Kenney root selected");
filter_input = tree.add_text_input("Search assets");
status_label = browser_status_label(tree, "Pick a Kenney directory or .zip to begin");
tab_bar = tree.add_tab_bar(&["All assets", "Packs"], 0);
all_view = tree.add_node().fill_width().flex_grow(1.0).entity();
all_grid_root = tree.in_parent(all_view, |tree| browser_grid_root(tree));
packs_view = tree.add_node().fill_width().flex_grow(1.0).entity();
packs_root = tree.in_parent(packs_view, |tree| {
let wrapper = tree.add_node().fill_width().flex_grow(1.0).entity();
let scroll = tree.in_parent(wrapper, |tree| tree.add_scroll_area_fill(8.0, 8.0));
widget::<UiScrollAreaData>(tree.world_mut(), scroll)
.map(|d| d.content_entity)
.unwrap_or(scroll)
});
ui_set_visible(tree.world_mut(), packs_view, false);
});
KenneyHandles {
panel,
status_label,
root_label,
pick_directory_button,
pick_zip_button,
filter_input,
tab_bar,
all_view,
all_grid_root,
packs_view,
packs_root,
..Default::default()
}
}
fn browser_status_label(tree: &mut UiTreeBuilder, initial: &str) -> Entity {
let theme = tree
.world_mut()
.resources
.retained_ui
.theme_state
.active_theme();
let font = theme.font_size;
let dim = vec4(0.7, 0.7, 0.75, 1.0);
tree.add_node()
.size(100.pct(), (18.0).px())
.with_text(initial, font * 0.85)
.text_left()
.color_raw::<UiBase>(dim)
.entity()
}
pub fn poll(editor_world: &mut EditorWorld, world: &mut World, handles: &UiHandles) {
let mut new_actions: Vec<Action> = Vec::new();
{
let browsers_handles = &editor_world.resources.ui_handles.browsers;
for event in ui_events(world) {
let UiEvent::ButtonClicked(entity) = event else {
continue;
};
if let Some(&index) = browsers_handles.khronos.lookup.get(entity) {
new_actions.push(Action::LoadKhronosByIndex(index));
continue;
}
if let Some(&index) = browsers_handles.polyhaven.hdri_lookup.get(entity) {
new_actions.push(Action::LoadPolyhavenByIndex(Category::Hdris, index));
continue;
}
if let Some(&index) = browsers_handles.polyhaven.model_lookup.get(entity) {
new_actions.push(Action::LoadPolyhavenByIndex(Category::Models, index));
continue;
}
#[cfg(not(target_arch = "wasm32"))]
if *entity == handles.browsers.sketchfab.token_link {
open_url_in_browser("https://sketchfab.com/settings/password");
continue;
}
#[cfg(not(target_arch = "wasm32"))]
if *entity == handles.browsers.sketchfab.fetch_button {
let token =
widget::<UiTextInputData>(world, handles.browsers.sketchfab.token_input)
.map(|d| d.text.clone())
.unwrap_or_default();
let url = widget::<UiTextInputData>(world, handles.browsers.sketchfab.url_input)
.map(|d| d.text.clone())
.unwrap_or_default();
new_actions.push(Action::SketchfabFetch { token, url });
continue;
}
#[cfg(not(target_arch = "wasm32"))]
if *entity == handles.browsers.kenney.pick_directory_button {
new_actions.push(Action::PickKenneyDirectory);
continue;
}
#[cfg(not(target_arch = "wasm32"))]
if *entity == handles.browsers.kenney.pick_zip_button {
new_actions.push(Action::PickKenneyZip);
continue;
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(&index) = browsers_handles.kenney.lookup.get(entity) {
new_actions.push(Action::LoadKenneyByIndex(index));
continue;
}
let _ = handles;
}
}
editor_world
.resources
.ui_interaction
.actions
.extend(new_actions);
}
pub fn update(editor_world: &mut EditorWorld, world: &mut World) {
update_khronos(editor_world, world);
update_polyhaven(editor_world, world);
#[cfg(not(target_arch = "wasm32"))]
update_sketchfab(editor_world, world);
#[cfg(not(target_arch = "wasm32"))]
update_kenney(editor_world, world);
}
fn build_thumbnail_card(tree: &mut UiTreeBuilder, label_text: &str) -> ThumbnailCard {
let theme = tree
.world_mut()
.resources
.retained_ui
.theme_state
.active_theme();
let bg = theme.input_background_color;
let hover = theme.background_color_hovered;
let border = theme.border_color;
let text_color = theme.text_color;
let font = theme.font_size;
let card = tree
.add_node()
.size((132.0).px(), (124.0).px())
.flow(FlowDirection::Vertical, 4.0, 4.0)
.with_rect(6.0, 1.0, border)
.color_raw::<UiBase>(bg)
.color_raw::<UiHover>(hover)
.with_interaction()
.with_transition::<UiHover>(10.0, 6.0)
.with_cursor_icon(winit::window::CursorIcon::Pointer)
.entity();
let mut image = Entity::default();
tree.in_parent(card, |tree| {
let slot = tree.add_node().size((124.0).px(), (84.0).px()).entity();
image = tree.in_parent(slot, |tree| {
tree.add_node()
.solid(Ab(vec2(1.0, 1.0)), ScalingMode::Fit, vec2(0.0, 0.0))
.color_raw::<UiBase>(vec4(1.0, 1.0, 1.0, 1.0))
.entity()
});
tree.add_node()
.size((124.0).px(), (24.0).px())
.with_text(label_text, font * 0.75)
.text_center()
.with_text_overflow(TextOverflow::Clip)
.color_raw::<UiBase>(text_color)
.entity();
});
ThumbnailCard { card, image }
}
fn refresh_thumbnail_textures(editor_world: &mut EditorWorld, world: &mut World) {
let cards = editor_world
.resources
.ui_handles
.browsers
.khronos
.cards
.clone();
let lookup = editor_world
.resources
.ui_handles
.browsers
.khronos
.lookup
.clone();
let entries = editor_world.resources.browsers.sample_browser.entries();
let Some(entries) = entries else {
return;
};
let mut dirty = false;
for card in &cards {
let Some(&entry_index) = lookup.get(&card.card) else {
continue;
};
let Some(entry) = entries.get(entry_index) else {
continue;
};
let thumb_name = format!("khronos_thumb_{}", entry.name);
let Some(thumb) = editor_world
.resources
.browsers
.thumbnail_slots
.get(&thumb_name)
.copied()
else {
continue;
};
if patch_thumbnail_image(world, card.image, thumb) {
dirty = true;
}
}
if dirty {
ui_mark_render_dirty(world);
}
}
fn patch_thumbnail_image(
world: &mut World,
image_entity: Entity,
thumb: crate::ecs::ThumbnailSlot,
) -> bool {
let already_matches = match world.ui.get_ui_node_content(image_entity) {
Some(UiNodeContent::Image {
texture_index,
uv_min,
uv_max,
}) => *texture_index == thumb.layer && *uv_min == thumb.uv_min && *uv_max == thumb.uv_max,
_ => false,
};
if already_matches {
return false;
}
if let Some(node) = world.ui.get_ui_layout_node_mut(image_entity) {
node.base_layout = Some(UiLayoutType::Solid(SolidLayout {
size: Ab(thumb.aspect).into(),
scaling: ScalingMode::Fit,
alignment: vec2(0.0, 0.0),
}));
}
if let Some(content) = world.ui.get_ui_node_content_mut(image_entity) {
*content = UiNodeContent::Image {
texture_index: thumb.layer,
uv_min: thumb.uv_min,
uv_max: thumb.uv_max,
};
}
ui_mark_layout_dirty(world);
true
}
fn release_khronos_thumbnails(editor_world: &mut EditorWorld, world: &mut World) {
let slots = std::mem::take(&mut editor_world.resources.browsers.thumbnail_slots);
for (name, slot) in slots {
if name.starts_with("khronos_thumb_") {
ui_release_image_layer(world, slot.layer);
} else {
editor_world
.resources
.browsers
.thumbnail_slots
.insert(name, slot);
}
}
editor_world.resources.browsers.thumbnail_signature = 0;
}
fn build_card_grid(tree_builder: &mut UiTreeBuilder) -> Entity {
tree_builder
.add_node()
.fill_width()
.auto_size(AutoSizeMode::Height)
.flow(FlowDirection::Horizontal, 0.0, 8.0)
.flow_wrap()
.entity()
}
fn update_khronos(editor_world: &mut EditorWorld, world: &mut World) {
let entries = editor_world.resources.browsers.sample_browser.entries();
let signature = entries.as_ref().map(|e| e.len() as u64).unwrap_or(u64::MAX);
let status_label = editor_world
.resources
.ui_handles
.browsers
.khronos
.status_label;
let grid_root = editor_world.resources.ui_handles.browsers.khronos.grid_root;
let filter_input = editor_world
.resources
.ui_handles
.browsers
.khronos
.filter_input;
let current_filter = widget::<UiTextInputData>(world, filter_input)
.map(|data| data.text.clone())
.unwrap_or_default();
let filter_changed = current_filter
!= editor_world
.resources
.ui_handles
.browsers
.khronos
.filter_last;
if filter_changed {
editor_world
.resources
.ui_handles
.browsers
.khronos
.filter_last = current_filter.clone();
}
if signature != editor_world.resources.ui_handles.browsers.khronos.signature || filter_changed {
editor_world.resources.ui_handles.browsers.khronos.signature = signature;
let prior_grid =
std::mem::take(&mut editor_world.resources.ui_handles.browsers.khronos.grid);
if prior_grid != Entity::default() {
despawn_recursive_immediate(world, prior_grid);
}
editor_world
.resources
.ui_handles
.browsers
.khronos
.cards
.clear();
if !filter_changed {
release_khronos_thumbnails(editor_world, world);
}
editor_world
.resources
.ui_handles
.browsers
.khronos
.lookup
.clear();
match entries {
Some(entries) => {
let needle = current_filter.to_lowercase();
let filtered: Vec<(usize, &crate::systems::sample_assets::AssetEntry)> = entries
.iter()
.enumerate()
.filter(|(_, entry)| {
if needle.is_empty() {
return true;
}
entry.label.to_lowercase().contains(&needle)
|| entry.name.to_lowercase().contains(&needle)
})
.collect();
ui_set_text(
world,
status_label,
&format!("{} / {} assets", filtered.len(), entries.len()),
);
let mut new_cards: Vec<ThumbnailCard> = Vec::with_capacity(filtered.len());
let card_indices: Vec<usize> = filtered.iter().map(|(index, _)| *index).collect();
let card_labels: Vec<String> = filtered
.iter()
.map(|(_, entry)| entry.label.clone())
.collect();
let new_grid = {
let mut tree_builder = UiTreeBuilder::from_parent(world, grid_root);
let grid = build_card_grid(&mut tree_builder);
tree_builder.in_parent(grid, |tree_builder| {
for label in &card_labels {
let card = build_thumbnail_card(tree_builder, label);
new_cards.push(card);
}
});
tree_builder.finish_subtree();
grid
};
editor_world.resources.ui_handles.browsers.khronos.grid = new_grid;
let lookup = &mut editor_world.resources.ui_handles.browsers.khronos.lookup;
for (card, original_index) in new_cards.iter().zip(card_indices.iter()) {
lookup.insert(card.card, *original_index);
}
editor_world.resources.ui_handles.browsers.khronos.cards = new_cards;
}
None => {
let text = match editor_world.resources.browsers.sample_browser.index_error() {
Some(error) => format!("failed: {error}"),
None => "Loading index...".to_string(),
};
ui_set_text(world, status_label, &text);
}
}
}
refresh_thumbnail_textures(editor_world, world);
if editor_world
.resources
.browsers
.sample_browser
.entries()
.is_none()
&& let Some(error) = editor_world.resources.browsers.sample_browser.index_error()
{
ui_set_text(world, status_label, &format!("failed: {error}"));
}
}
fn update_polyhaven(editor_world: &mut EditorWorld, world: &mut World) {
let tab_bar = editor_world.resources.ui_handles.browsers.polyhaven.tab_bar;
let filter_input = editor_world
.resources
.ui_handles
.browsers
.polyhaven
.filter_input;
let status_label = editor_world.resources.ui_handles.browsers.polyhaven.status;
let list_root = editor_world
.resources
.ui_handles
.browsers
.polyhaven
.list_root;
let active_tab = widget::<UiTabBarData>(world, tab_bar)
.map(|data| data.selected_tab)
.unwrap_or(0);
let filter_text = widget::<UiTextInputData>(world, filter_input)
.map(|data| data.text.clone())
.unwrap_or_default();
let category = if active_tab == 0 {
Category::Hdris
} else {
Category::Models
};
let entries = editor_world
.resources
.browsers
.polyhaven_browser
.entries(category);
let entry_signature = entries.as_ref().map(|e| e.len() as u64).unwrap_or(u64::MAX);
let prior_signature = match category {
Category::Hdris => {
editor_world
.resources
.ui_handles
.browsers
.polyhaven
.hdris_signature
}
Category::Models => {
editor_world
.resources
.ui_handles
.browsers
.polyhaven
.models_signature
}
};
let tab_changed = active_tab
!= editor_world
.resources
.ui_handles
.browsers
.polyhaven
.last_tab;
let filter_changed = filter_text
!= editor_world
.resources
.ui_handles
.browsers
.polyhaven
.last_filter;
let signature_changed = entry_signature != prior_signature;
if !tab_changed && !filter_changed && !signature_changed {
if entries.is_none()
&& let Some(error) = editor_world
.resources
.browsers
.polyhaven_browser
.index_error(category)
{
ui_set_text(world, status_label, &format!("failed: {error}"));
}
return;
}
editor_world
.resources
.ui_handles
.browsers
.polyhaven
.last_tab = active_tab;
editor_world
.resources
.ui_handles
.browsers
.polyhaven
.last_filter = filter_text.clone();
match category {
Category::Hdris => {
editor_world
.resources
.ui_handles
.browsers
.polyhaven
.hdris_signature = entry_signature
}
Category::Models => {
editor_world
.resources
.ui_handles
.browsers
.polyhaven
.models_signature = entry_signature
}
}
let prior = std::mem::take(
&mut editor_world
.resources
.ui_handles
.browsers
.polyhaven
.list_items,
);
for entity in prior {
despawn_recursive_immediate(world, entity);
}
{
let polyhaven_handles = &mut editor_world.resources.ui_handles.browsers.polyhaven;
polyhaven_handles.hdri_lookup.clear();
polyhaven_handles.model_lookup.clear();
}
match entries {
Some(entries) => {
let needle = filter_text.to_lowercase();
const MAX_DISPLAY: usize = 200;
let filtered: Vec<(usize, &crate::systems::polyhaven::AssetEntry)> = entries
.iter()
.enumerate()
.filter(|(_, entry)| {
if needle.is_empty() {
return true;
}
entry.name.to_lowercase().contains(&needle)
|| entry.slug.to_lowercase().contains(&needle)
})
.take(MAX_DISPLAY)
.collect();
let total_matches = if needle.is_empty() {
entries.len()
} else {
entries
.iter()
.filter(|entry| {
entry.name.to_lowercase().contains(&needle)
|| entry.slug.to_lowercase().contains(&needle)
})
.count()
};
let shown = filtered.len();
ui_set_text(
world,
status_label,
&format!(
"{shown} of {total_matches} matches ({} total)",
entries.len()
),
);
let row_indices: Vec<usize> = filtered.iter().map(|(index, _)| *index).collect();
let row_labels: Vec<String> = filtered
.iter()
.map(|(_, entry)| entry.name.clone())
.collect();
let mut new_items: Vec<Entity> = Vec::with_capacity(filtered.len());
{
let mut tree_builder = UiTreeBuilder::from_parent(world, list_root);
for label in &row_labels {
let row = build_polyhaven_row(&mut tree_builder, label);
new_items.push(row);
}
tree_builder.finish_subtree();
}
let target = match category {
Category::Hdris => {
&mut editor_world
.resources
.ui_handles
.browsers
.polyhaven
.hdri_lookup
}
Category::Models => {
&mut editor_world
.resources
.ui_handles
.browsers
.polyhaven
.model_lookup
}
};
for (row, original_index) in new_items.iter().zip(row_indices.iter()) {
target.insert(*row, *original_index);
}
editor_world
.resources
.ui_handles
.browsers
.polyhaven
.list_items = new_items;
}
None => {
let text = match editor_world
.resources
.browsers
.polyhaven_browser
.index_error(category)
{
Some(error) => format!("failed: {error}"),
None => "Loading index...".to_string(),
};
ui_set_text(world, status_label, &text);
}
}
}
fn build_polyhaven_row(tree: &mut UiTreeBuilder, label: &str) -> Entity {
let theme = tree
.world_mut()
.resources
.retained_ui
.theme_state
.active_theme();
let font = theme.font_size;
tree.add_node()
.size(100.pct(), (24.0).px())
.rect(2.0)
.color_raw::<UiBase>(vec4(0.0, 0.0, 0.0, 0.0))
.fg_hover(ThemeColor::BackgroundHover)
.with_interaction()
.with_transition::<UiHover>(8.0, 6.0)
.with_cursor_icon(winit::window::CursorIcon::Pointer)
.with_children(|tree| {
tree.add_node()
.window(
Ab(vec2(8.0, 0.0)),
Rl(vec2(100.0, 100.0)) + Ab(vec2(-8.0, 0.0)),
Anchor::TopLeft,
)
.with_text(label, font * 0.85)
.text_left()
.with_text_overflow(TextOverflow::Ellipsis)
.fg(ThemeColor::Text)
.entity();
})
.entity()
}
#[cfg(not(target_arch = "wasm32"))]
fn build_pack_sections(
world: &mut World,
parent: Entity,
entries: &[crate::systems::kenney_assets::KenneyEntry],
needle: &str,
) -> Vec<KenneyPackSection> {
let mut by_pack: std::collections::BTreeMap<(String, String), Vec<usize>> =
std::collections::BTreeMap::new();
for (index, entry) in entries.iter().enumerate() {
if !needle.is_empty()
&& !entry.display_name.to_lowercase().contains(needle)
&& !entry.pack.to_lowercase().contains(needle)
{
continue;
}
by_pack
.entry((entry.category.clone(), entry.pack.clone()))
.or_default()
.push(index);
}
let mut sections = Vec::with_capacity(by_pack.len());
let mut tree = UiTreeBuilder::from_parent(world, parent);
for ((category, pack), indices) in by_pack {
let count = indices.len();
let label = if category.is_empty() {
format!("{pack} ({count})")
} else {
format!("{pack} ({count}) - {category}")
};
let header = tree.add_collapsing_header(&label, false);
let content = widget::<UiCollapsingHeaderData>(tree.world_mut(), header)
.map(|d| d.content_entity)
.unwrap_or(header);
sections.push(KenneyPackSection {
header,
content,
entry_indices: indices,
built: false,
last_open: false,
cards: Vec::new(),
});
}
tree.finish_subtree();
sections
}
#[cfg(not(target_arch = "wasm32"))]
fn sync_pack_sections(editor_world: &mut EditorWorld, world: &mut World) {
let entries = match editor_world.resources.browsers.kenney_browser.entries() {
Some(entries) => entries,
None => return,
};
let mut sections = std::mem::take(
&mut editor_world
.resources
.ui_handles
.browsers
.kenney
.pack_sections,
);
let mut lookup_changes: Vec<(Entity, usize)> = Vec::new();
let mut lookup_removals: Vec<Entity> = Vec::new();
let mut new_cards: Vec<ThumbnailCard> = Vec::new();
for section in &mut sections {
let open = widget::<UiCollapsingHeaderData>(world, section.header)
.map(|data| data.open)
.unwrap_or(false);
if open == section.last_open && open == section.built {
continue;
}
section.last_open = open;
if open && !section.built {
let mut tree = UiTreeBuilder::from_parent(world, section.content);
let grid = build_card_grid(&mut tree);
tree.in_parent(grid, |tree| {
for &entry_index in §ion.entry_indices {
let label = entries[entry_index].display_name.clone();
let card = build_thumbnail_card(tree, &label);
lookup_changes.push((card.card, entry_index));
section.cards.push(card);
new_cards.push(card);
}
});
tree.finish_subtree();
section.built = true;
} else if !open && section.built {
for card in section.cards.drain(..) {
lookup_removals.push(card.card);
despawn_recursive_immediate(world, card.card);
}
section.built = false;
}
}
let kenney = &mut editor_world.resources.ui_handles.browsers.kenney;
kenney.pack_sections = sections;
for entity in lookup_removals {
kenney.lookup.remove(&entity);
kenney.cards.retain(|card| card.card != entity);
}
for (entity, index) in lookup_changes {
kenney.lookup.insert(entity, index);
}
kenney.cards.extend(new_cards);
}
#[cfg(not(target_arch = "wasm32"))]
fn update_kenney(editor_world: &mut EditorWorld, world: &mut World) {
let entries = editor_world.resources.browsers.kenney_browser.entries();
let entry_signature = entries.as_ref().map(|e| e.len() as u64).unwrap_or(u64::MAX);
let root_signature = editor_world
.resources
.browsers
.kenney_browser
.root()
.map(|path| path.display().to_string())
.unwrap_or_default();
let status_text = editor_world.resources.browsers.kenney_browser.status_text();
let status_label = editor_world
.resources
.ui_handles
.browsers
.kenney
.status_label;
let root_label = editor_world.resources.ui_handles.browsers.kenney.root_label;
let all_view = editor_world.resources.ui_handles.browsers.kenney.all_view;
let all_grid_root = editor_world
.resources
.ui_handles
.browsers
.kenney
.all_grid_root;
let packs_view = editor_world.resources.ui_handles.browsers.kenney.packs_view;
let packs_root = editor_world.resources.ui_handles.browsers.kenney.packs_root;
let tab_bar = editor_world.resources.ui_handles.browsers.kenney.tab_bar;
let filter_input = editor_world
.resources
.ui_handles
.browsers
.kenney
.filter_input;
if status_text
!= editor_world
.resources
.ui_handles
.browsers
.kenney
.last_status_text
{
ui_set_text(world, status_label, &status_text);
editor_world
.resources
.ui_handles
.browsers
.kenney
.last_status_text = status_text;
}
let root_display = if root_signature.is_empty() {
"No Kenney root selected".to_string()
} else {
format!("Root: {root_signature}")
};
if root_display
!= editor_world
.resources
.ui_handles
.browsers
.kenney
.last_root_text
{
ui_set_text(world, root_label, &root_display);
editor_world
.resources
.ui_handles
.browsers
.kenney
.last_root_text = root_display;
}
let current_filter = widget::<UiTextInputData>(world, filter_input)
.map(|data| data.text.clone())
.unwrap_or_default();
let active_tab = widget::<UiTabBarData>(world, tab_bar)
.map(|data| data.selected_tab)
.unwrap_or(0);
let filter_changed = current_filter
!= editor_world
.resources
.ui_handles
.browsers
.kenney
.filter_last;
let root_changed = root_signature
!= editor_world
.resources
.ui_handles
.browsers
.kenney
.root_signature;
let entries_changed = entry_signature
!= editor_world
.resources
.ui_handles
.browsers
.kenney
.entries_signature;
let tab_changed = active_tab != editor_world.resources.ui_handles.browsers.kenney.last_tab;
if filter_changed {
editor_world
.resources
.ui_handles
.browsers
.kenney
.filter_last = current_filter.clone();
}
if root_changed {
editor_world
.resources
.ui_handles
.browsers
.kenney
.root_signature = root_signature;
}
if tab_changed {
editor_world.resources.ui_handles.browsers.kenney.last_tab = active_tab;
ui_set_visible(world, all_view, active_tab == 0);
ui_set_visible(world, packs_view, active_tab == 1);
}
if !entries_changed && !filter_changed && !root_changed && !tab_changed {
sync_pack_sections(editor_world, world);
refresh_kenney_thumbnails(editor_world, world);
return;
}
if entries_changed {
editor_world
.resources
.ui_handles
.browsers
.kenney
.entries_signature = entry_signature;
if root_changed {
release_kenney_thumbnails(editor_world, world);
}
}
let prior_cards = std::mem::take(&mut editor_world.resources.ui_handles.browsers.kenney.cards);
for card in prior_cards {
despawn_recursive_immediate(world, card.card);
}
let prior_sections = std::mem::take(
&mut editor_world
.resources
.ui_handles
.browsers
.kenney
.pack_sections,
);
for section in prior_sections {
despawn_recursive_immediate(world, section.header);
}
editor_world
.resources
.ui_handles
.browsers
.kenney
.lookup
.clear();
let Some(entries) = entries else {
return;
};
let needle = current_filter.to_lowercase();
if active_tab == 0 {
const MAX_DISPLAY: usize = 200;
let filtered: Vec<(usize, &crate::systems::kenney_assets::KenneyEntry)> = entries
.iter()
.enumerate()
.filter(|(_, entry)| {
if needle.is_empty() {
return true;
}
entry.display_name.to_lowercase().contains(&needle)
|| entry.pack.to_lowercase().contains(&needle)
})
.take(MAX_DISPLAY)
.collect();
let card_indices: Vec<usize> = filtered.iter().map(|(index, _)| *index).collect();
let card_labels: Vec<String> = filtered
.iter()
.map(|(_, entry)| entry.display_name.clone())
.collect();
let mut new_cards: Vec<ThumbnailCard> = Vec::with_capacity(card_labels.len());
{
let mut tree_builder = UiTreeBuilder::from_parent(world, all_grid_root);
let grid = build_card_grid(&mut tree_builder);
tree_builder.in_parent(grid, |tree_builder| {
for label in &card_labels {
let card = build_thumbnail_card(tree_builder, label);
new_cards.push(card);
}
});
tree_builder.finish_subtree();
}
let lookup = &mut editor_world.resources.ui_handles.browsers.kenney.lookup;
for (card, original_index) in new_cards.iter().zip(card_indices.iter()) {
lookup.insert(card.card, *original_index);
}
editor_world.resources.ui_handles.browsers.kenney.cards = new_cards;
} else {
let sections = build_pack_sections(world, packs_root, entries.as_ref(), &needle);
editor_world
.resources
.ui_handles
.browsers
.kenney
.pack_sections = sections;
}
sync_pack_sections(editor_world, world);
refresh_kenney_thumbnails(editor_world, world);
}
#[cfg(not(target_arch = "wasm32"))]
fn refresh_kenney_thumbnails(editor_world: &mut EditorWorld, world: &mut World) {
let cards = editor_world
.resources
.ui_handles
.browsers
.kenney
.cards
.clone();
let lookup = editor_world
.resources
.ui_handles
.browsers
.kenney
.lookup
.clone();
let Some(entries) = editor_world.resources.browsers.kenney_browser.entries() else {
return;
};
let mut dirty = false;
for card in &cards {
let Some(&entry_index) = lookup.get(&card.card) else {
continue;
};
let Some(entry) = entries.get(entry_index) else {
continue;
};
let thumb_name = format!("kenney_thumb_{}", entry.asset_name);
let Some(thumb) = editor_world
.resources
.browsers
.thumbnail_slots
.get(&thumb_name)
.copied()
else {
continue;
};
if patch_thumbnail_image(world, card.image, thumb) {
dirty = true;
}
}
if dirty {
ui_mark_render_dirty(world);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn release_kenney_thumbnails(editor_world: &mut EditorWorld, world: &mut World) {
let slots = std::mem::take(&mut editor_world.resources.browsers.thumbnail_slots);
for (name, slot) in slots {
if name.starts_with("kenney_thumb_") {
ui_release_image_layer(world, slot.layer);
} else {
editor_world
.resources
.browsers
.thumbnail_slots
.insert(name, slot);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn update_sketchfab(editor_world: &EditorWorld, world: &mut World) {
use crate::systems::sketchfab::FetchStatus;
let status = editor_world.resources.browsers.sketchfab_browser.status();
let label = editor_world
.resources
.ui_handles
.browsers
.sketchfab
.status_label;
let text = match status {
FetchStatus::Idle => "idle".to_string(),
FetchStatus::Working(message) => format!("working: {message}"),
FetchStatus::Failed(message) => format!("failed: {message}"),
};
ui_set_text(world, label, &text);
}