From 0150a9845f70a8d1a7b3ddfc7b2df5715f052ced Mon Sep 17 00:00:00 2001 From: hcabel Date: Sun, 13 Oct 2024 00:37:27 +1000 Subject: [PATCH 01/26] bevy_asset_browser 1st iteration Basic asset browser UI - Replicate the asset directory on disk - Auto sync - Folder navigation --- .../bevy_asset_browser/Cargo.toml | 2 + .../src/directory_content.rs | 216 +++++++++++++++++ .../bevy_asset_browser/src/lib.rs | 225 +++++++++++++++++- .../bevy_asset_browser/src/top_bar.rs | 98 ++++++++ 4 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 bevy_editor_panes/bevy_asset_browser/src/directory_content.rs create mode 100644 bevy_editor_panes/bevy_asset_browser/src/top_bar.rs diff --git a/bevy_editor_panes/bevy_asset_browser/Cargo.toml b/bevy_editor_panes/bevy_asset_browser/Cargo.toml index f3cb9b2..8d207a0 100644 --- a/bevy_editor_panes/bevy_asset_browser/Cargo.toml +++ b/bevy_editor_panes/bevy_asset_browser/Cargo.toml @@ -5,6 +5,8 @@ edition = "2021" [dependencies] bevy.workspace = true +bevy_editor_styles.workspace = true +bevy_pane_layout.workspace = true [lints] workspace = true diff --git a/bevy_editor_panes/bevy_asset_browser/src/directory_content.rs b/bevy_editor_panes/bevy_asset_browser/src/directory_content.rs new file mode 100644 index 0000000..255501a --- /dev/null +++ b/bevy_editor_panes/bevy_asset_browser/src/directory_content.rs @@ -0,0 +1,216 @@ +use std::path::PathBuf; + +use bevy::{ + a11y::{ + accesskit::{NodeBuilder, Role}, + AccessibilityNode, + }, + input::mouse::{MouseScrollUnit, MouseWheel}, + prelude::*, + text::BreakLineOn, +}; +use bevy_editor_styles::Theme; + +use crate::AssetBrowserLocation; + +/// The root node for the directory content view +#[derive(Component)] +pub struct DirectoryContentNode; + +pub fn ui_setup( + mut commands: Commands, + root: Query>, + theme: Res, +) { + commands + .entity(root.single()) + .insert(NodeBundle { + style: Style { + flex_direction: FlexDirection::Column, + width: Val::Percent(100.0), + height: Val::Percent(100.0), + align_self: AlignSelf::Stretch, + overflow: Overflow::clip_y(), + ..default() + }, + background_color: theme.pane_background_color, + ..default() + }) + .with_children(|parent| { + // Moving panel + parent.spawn(( + NodeBundle { + style: Style { + position_type: PositionType::Absolute, + flex_wrap: FlexWrap::Wrap, + ..default() + }, + ..default() + }, + ScrollingList::default(), + AccessibilityNode(NodeBuilder::new(Role::Grid)), + )); + }); +} + +pub enum FetchDirectoryContentResult { + Success(Vec), + UpToDate, +} + +pub fn fetch_directory_content( + location: &mut ResMut, + last_modified_time: &mut ResMut, +) -> FetchDirectoryContentResult { + let metadata = { + if let Ok(metadata) = std::fs::metadata(&location.0) { + metadata + } else { + location.0 = PathBuf::from(crate::ASSETS_DIRECTORY_PATH); + let content_directory_exist = + std::fs::exists(crate::ASSETS_DIRECTORY_PATH).unwrap_or(false); + if !content_directory_exist { + std::fs::create_dir_all(crate::ASSETS_DIRECTORY_PATH).unwrap(); + } + std::fs::metadata(&location.0).unwrap() + } + }; + let modified_time = metadata.modified().unwrap(); + if modified_time == last_modified_time.0 { + return FetchDirectoryContentResult::UpToDate; + } + last_modified_time.0 = metadata.modified().unwrap(); + + let mut dir_content = std::fs::read_dir(&location.0) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + // Sort, directorys first in alphabetical order, then files in alphabetical order toot + dir_content.sort_by(|a, b| { + if a.is_dir() && b.is_file() { + std::cmp::Ordering::Less + } else if a.is_file() && b.is_dir() { + std::cmp::Ordering::Greater + } else { + a.cmp(b) + } + }); + return FetchDirectoryContentResult::Success(dir_content); +} + +#[derive(Component, Default)] +pub struct ScrollingList { + position: f32, +} + +pub fn scrolling( + mut mouse_wheel_events: EventReader, + mut query_list: Query<(&mut ScrollingList, &mut Style, &Parent, &Node)>, + query_node: Query<&Node>, +) { + for mouse_wheel_event in mouse_wheel_events.read() { + for (mut scrolling_list, mut style, parent, list_node) in &mut query_list { + let items_height = list_node.size().y; + let container_height = query_node.get(parent.get()).unwrap().size().y; + let max_scroll = (items_height - container_height).max(0.); + + let dy = match mouse_wheel_event.unit { + MouseScrollUnit::Line => mouse_wheel_event.y * 20., + MouseScrollUnit::Pixel => mouse_wheel_event.y, + }; + + scrolling_list.position += dy; + scrolling_list.position = scrolling_list.position.clamp(-max_scroll, 0.); + + style.top = Val::Px(scrolling_list.position); + } + } +} + +pub fn refresh_content( + commands: &mut Commands, + content_list_query: &Query<(Entity, Option<&Children>), With>, + last_modified_time: &mut ResMut, + location: &mut ResMut, + theme: &Res, + asset_server: &Res, +) { + match fetch_directory_content(location, last_modified_time) { + FetchDirectoryContentResult::Success(directory_content) => { + let (content_list_entity, content_list_childs) = content_list_query.single(); + if let Some(content_list_childs) = content_list_childs { + for child in content_list_childs.iter() { + commands.entity(*child).despawn_recursive(); + } + commands + .entity(content_list_entity) + .remove_children(content_list_childs); + } + commands + .entity(content_list_entity) + .with_children(|parent| { + for entry in directory_content { + let asset_type = if entry.is_dir() { + crate::AssetType::Directory + } else { + crate::AssetType::Unknown + }; + parent + .spawn(( + ButtonBundle { + style: Style { + margin: UiRect::all(Val::Px(5.0)), + padding: UiRect::all(Val::Px(5.0)), + height: Val::Px(100.0), + width: Val::Px(100.0), + align_items: AlignItems::Center, + flex_direction: FlexDirection::Column, + border: UiRect::all(Val::Px(3.0)), + justify_content: JustifyContent::SpaceBetween, + ..default() + }, + border_radius: theme.border_radius, + ..default() + }, + crate::ButtonType::AssetButton(asset_type.clone()), + )) + .with_children(|parent| { + parent.spawn(ImageBundle { + image: UiImage::new(crate::content_button_to_icon( + &asset_type, + asset_server, + )), + style: Style { + height: Val::Px(50.0), + ..default() + }, + ..default() + }); + parent.spawn(TextBundle { + text: Text { + sections: vec![TextSection { + value: entry + .file_name() + .to_owned() + .unwrap() + .to_str() + .unwrap() + .to_string(), + style: TextStyle { + font_size: 12.0, + color: theme.text_color, + ..default() + }, + }], + linebreak_behavior: BreakLineOn::WordBoundary, + justify: JustifyText::Center, + }, + ..default() + }); + }); + } + }); + } + FetchDirectoryContentResult::UpToDate => {} + } +} diff --git a/bevy_editor_panes/bevy_asset_browser/src/lib.rs b/bevy_editor_panes/bevy_asset_browser/src/lib.rs index 310a7c4..94d7320 100644 --- a/bevy_editor_panes/bevy_asset_browser/src/lib.rs +++ b/bevy_editor_panes/bevy_asset_browser/src/lib.rs @@ -1 +1,224 @@ -//! Asset browser pane +//! A UI element for browsing assets in the Bevy Editor. +/// The intent of this system is to provide a simple and frictionless way to browse assets in the Bevy Editor. +/// The asset browser is a replica of the your asset directory on disk and get's automatically updated when the directory is modified. +use std::{path::PathBuf, time::SystemTime}; + +use bevy::prelude::*; +use bevy_editor_styles::Theme; +use directory_content::{DirectoryContentNode, ScrollingList}; +use top_bar::TopBarNode; + +mod directory_content; +mod top_bar; + +/// The path to the "assets" directory on disk +pub const ASSETS_DIRECTORY_PATH: &str = "./assets"; + +/// The bevy asset browser plugin +pub struct AssetBrowserPlugin; + +impl Plugin for AssetBrowserPlugin { + fn build(&self, app: &mut App) { + app.insert_resource(AssetBrowserLocation(PathBuf::from(ASSETS_DIRECTORY_PATH))) + .insert_resource(DirectoryLastModifiedTime(SystemTime::UNIX_EPOCH)) + .add_systems(Startup, ui_setup.in_set(AssetBrowserSet)) + .add_systems( + Startup, + (top_bar::ui_setup, directory_content::ui_setup).after(AssetBrowserSet), + ) + .add_systems(Update, button_interaction) + .add_systems(Update, directory_content::scrolling) + .add_systems(FixedUpdate, refresh_directory_content); + } +} + +/// System Set to set up the Asset Browser. +#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] +pub struct AssetBrowserSet; + +/// The current location of the asset browser +#[derive(Resource)] +pub struct AssetBrowserLocation(pub PathBuf); + +#[derive(Resource)] +pub struct DirectoryLastModifiedTime(pub SystemTime); + +/// The root node for the asset browser. +#[derive(Component)] +pub struct AssetBrowserNode; + +fn ui_setup( + mut commands: Commands, + root: Query>, + theme: Res, +) { + commands + .entity(root.single()) + .insert(NodeBundle { + style: Style { + width: Val::Percent(100.0), + height: Val::Percent(35.0), + display: Display::Flex, + flex_direction: FlexDirection::Column, + ..Default::default() + }, + background_color: theme.background_color, + ..Default::default() + }) + .with_children(|parent| { + parent.spawn(TopBarNode); + parent.spawn(DirectoryContentNode); + }); +} + +/// A system to automatically refresh the current directory content when the directory is modified. +pub fn refresh_directory_content( + mut commands: Commands, + content_list_query: Query<(Entity, Option<&Children>), With>, + mut last_modified_time: ResMut, + mut location: ResMut, + theme: Res, + asset_server: Res, +) { + directory_content::refresh_content( + &mut commands, + &content_list_query, + &mut last_modified_time, + &mut location, + &theme, + &asset_server, + ); +} + +/// Every type of button in the asset browser +#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] +pub enum ButtonType { + /// A Path segment of the current asset browser location + /// When clicked, the asset browser will navigate to the corresponding directory + LocationSegment, + /// An asset button + /// Used to interact with the assets in the directory content view + AssetButton(AssetType), +} + +/// Every type of asset the asset browser supports +#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)] +pub enum AssetType { + /// A type of asset that is not supported + #[default] + Unknown, + /// A directory assset + /// When clicked, the asset browser will step into the directory + Directory, +} + +/// Map the asset type to the corresponding icon +pub fn content_button_to_icon( + asset_type: &AssetType, + asset_server: &Res, +) -> Handle { + match asset_type { + AssetType::Directory => asset_server.load::("directory_icon.png"), + _ => asset_server.load::("file_icon.png"), + } +} + +/// Handle the asset browser button interactions +pub fn button_interaction( + mut commands: Commands, + mut interaction_query: Query< + ( + Entity, + &Interaction, + &ButtonType, + &mut BackgroundColor, + &Children, + ), + (Changed, With