diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bca19ea7..6cd7df68 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ on: env: CARGO_TERM_COLOR: always - RUSTFLAGS: --deny warnings + RUSTFLAGS: --deny warnings -C debuginfo=line-tables-only RUSTDOCFLAGS: --deny warnings # This can be any valid Cargo version requirement, but should start with a caret `^` to opt-in to # SemVer-compatible changes. Please keep this in sync with `book.yaml`. @@ -30,7 +30,33 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Install dependencies - run: sudo apt-get update; sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends libasound2-dev libudev-dev libwayland-dev libxkbcommon-dev + + # Uninstall unneeded tools + # .NET + sudo rm -rf /usr/share/dotnet || true + sudo apt-get remove -y '^aspnetcore-.*' || true + sudo apt-get remove -y '^dotnet-.*' --fix-missing || true + # Haskell + sudo rm -rf /opt/ghc || true + sudo rm -rf /usr/local/.ghcup || true + # Android + sudo rm -rf /usr/local/lib/android || true + # PHP + sudo apt-get remove -y 'php.*' --fix-missing || true + # Database + sudo apt-get remove -y '^mongodb-.*' --fix-missing || true + sudo apt-get remove -y '^mysql-.*' --fix-missing || true + # Cloud + sudo apt-get remove -y azure-cli google-chrome-stable firefox powershell mono-devel libgl1-mesa-dri --fix-missing || true + sudo apt-get remove -y google-cloud-sdk --fix-missing || true + sudo apt-get remove -y google-cloud-cli --fix-missing || true + + # Clean up unused packages + sudo apt-get autoremove -y + sudo apt-get clean - name: Populate target directory from cache uses: Leafwing-Studios/cargo-cache@v2 diff --git a/.gitignore b/.gitignore index 65a88e51..b5cb3408 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,20 @@ # Generated by Cargo # will have compiled files and executables -debug/ -target/ +target # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock +# Local cargo config overrides +.cargo/config +.cargo/config.toml + # These are backup files generated by rustfmt **/*.rs.bk # MSVC Windows builds of rustc generate these, which store debugging information *.pdb - -# Added by cargo - -/target - # mdbook generated files design-book/book diff --git a/Cargo.toml b/Cargo.toml index a9a5a24a..e8772d61 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -resolver = "2" +resolver = "3" members = ["crates/*", "bevy_editor_panes/*", "bevy_widgets/*"] exclude = ["templates/"] default-members = ["crates/bevy_editor_launcher"] @@ -27,18 +27,20 @@ unsafe_op_in_unsafe_fn = "warn" unused_qualifications = "warn" [workspace.dependencies] -bevy = { git = "https://github.com/bevyengine/bevy.git", rev = "a3d406dd497205253e34ace757ab0076d50eec14", features = [ - "wayland", -] } -bevy_derive = { git = "https://github.com/bevyengine/bevy.git", rev = "a3d406dd497205253e34ace757ab0076d50eec14" } -bevy_macro_utils = { git = "https://github.com/bevyengine/bevy.git", rev = "a3d406dd497205253e34ace757ab0076d50eec14" } -thiserror = "2.0" +bevy = { git = "https://github.com/cart/bevy.git", rev = "87a21ecafa51bfaea02834b5933f08397b45b984", features = ["experimental_bevy_feathers"] } +bevy_derive = { git = "https://github.com/cart/bevy.git", rev = "87a21ecafa51bfaea02834b5933f08397b45b984" } +bevy_macro_utils = { git = "https://github.com/cart/bevy.git", rev = "87a21ecafa51bfaea02834b5933f08397b45b984" } +bevy_remote = { git = "https://github.com/cart/bevy.git", rev = "87a21ecafa51bfaea02834b5933f08397b45b984" } + +thiserror = "2" serde = { version = "1", features = ["derive"] } +serde_json = "1.0.140" +ureq = {version = "3.0.12", features = ["json"]} tracing-test = "0.2.5" tracing = "0.1.41" atomicow = "1.1.0" -rfd = "0.15.3" -ron = "0.10.1" +rfd = "0.17.2" +ron = "0.12.0" variadics_please = "1.0" # local crates @@ -61,9 +63,12 @@ bevy_menu_bar = { path = "bevy_widgets/bevy_menu_bar" } bevy_scroll_box = { path = "bevy_widgets/bevy_scroll_box" } bevy_footer_bar = { path = "bevy_widgets/bevy_footer_bar" } bevy_toolbar = { path = "bevy_widgets/bevy_toolbar" } +bevy_gizmo_indicator = { path = "bevy_widgets/bevy_gizmo_indicator" } bevy_tooltips = { path = "bevy_widgets/bevy_tooltips" } bevy_text_editing = { path = "bevy_widgets/bevy_text_editing" } bevy_field_forms = { path = "bevy_widgets/bevy_field_forms" } +bevy_focus = { path = "bevy_widgets/bevy_focus" } +bevy_transform = { path = "bevy_widgets/bevy_transform" } # general crates bevy_editor_core = { path = "crates/bevy_editor_core" } diff --git a/bevy_editor_panes/bevy_2d_viewport/Cargo.toml b/bevy_editor_panes/bevy_2d_viewport/Cargo.toml index 479e3791..4b5b57f4 100644 --- a/bevy_editor_panes/bevy_2d_viewport/Cargo.toml +++ b/bevy_editor_panes/bevy_2d_viewport/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bevy_2d_viewport" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] bevy.workspace = true diff --git a/bevy_editor_panes/bevy_2d_viewport/src/lib.rs b/bevy_editor_panes/bevy_2d_viewport/src/lib.rs index b9efd252..035aa660 100644 --- a/bevy_editor_panes/bevy_2d_viewport/src/lib.rs +++ b/bevy_editor_panes/bevy_2d_viewport/src/lib.rs @@ -1,11 +1,10 @@ //! 2d Viewport for Bevy use bevy::{ + camera::{RenderTarget, visibility::RenderLayers}, + feathers::theme::ThemedText, prelude::*, - render::{ - camera::RenderTarget, - render_resource::{Extent3d, TextureFormat, TextureUsages}, - view::RenderLayers, - }, + render::render_resource::{Extent3d, TextureFormat, TextureUsages}, + scene2::{CommandsSpawnScene, bsn, on}, ui::ui_layout_system, }; use bevy_editor_camera::{EditorCamera2d, EditorCamera2dPlugin}; @@ -48,7 +47,7 @@ impl Plugin for Viewport2dPanePlugin { query: Query<&Bevy2dViewport>| { // Despawn the viewport camera commands - .entity(query.get(trigger.target()).unwrap().camera_id) + .entity(query.get(trigger.event().event_target()).unwrap().camera_id) .despawn(); }, ); @@ -87,20 +86,8 @@ fn on_pane_creation( let image_handle = images.add(image); - let image_id = commands - .spawn(( - ImageNode::new(image_handle.clone()), - Node { - position_type: PositionType::Absolute, - top: Val::ZERO, - bottom: Val::ZERO, - left: Val::ZERO, - right: Val::ZERO, - ..default() - }, - ChildOf(structure.content), - )) - .id(); + // Remove the existing structure + commands.entity(structure.area).despawn(); let camera_id = commands .spawn(( @@ -110,7 +97,7 @@ fn on_pane_creation( ..default() }, Camera { - target: RenderTarget::Image(image_handle.into()), + target: RenderTarget::Image(image_handle.clone().into()), clear_color: ClearColorConfig::Custom(theme.viewport.background_color), ..default() }, @@ -119,18 +106,25 @@ fn on_pane_creation( .id(); commands - .entity(image_id) - .observe( - move |_trigger: On>, mut query: Query<&mut EditorCamera2d>| { - let mut editor_camera = query.get_mut(camera_id).unwrap(); - editor_camera.enabled = true; - }, - ) - .observe( - move |_trigger: On>, mut query: Query<&mut EditorCamera2d>| { - query.get_mut(camera_id).unwrap().enabled = false; - }, - ); + .spawn_scene(bsn! { + :editor_pane [ + :editor_pane_header [ + (Text("2D Viewport") ThemedText), + ], + :editor_pane_body [ + ImageNode::new(image_handle.clone()) + :fit_to_parent + on(move |_trigger: On>, mut query: Query<&mut EditorCamera2d>| { + let mut editor_camera = query.get_mut(camera_id).unwrap(); + editor_camera.enabled = true; + }) + on(move |_trigger: On>, mut query: Query<&mut EditorCamera2d>| { + query.get_mut(camera_id).unwrap().enabled = false; + }) + ], + ] + }) + .insert(ChildOf(structure.root)); commands .entity(structure.root) @@ -149,10 +143,12 @@ fn update_render_target_size( mut images: ResMut>, ) { for (pane_root, viewport) in &query { - let content_node_id = children_query + let Some(content_node_id) = children_query .iter_descendants(pane_root) .find(|e| content.contains(*e)) - .unwrap(); + else { + continue; + }; let Ok((computed_node, global_transform)) = pos_query.get(content_node_id) else { continue; diff --git a/bevy_editor_panes/bevy_3d_viewport/Cargo.toml b/bevy_editor_panes/bevy_3d_viewport/Cargo.toml index 7bb80d70..6ad25fb1 100644 --- a/bevy_editor_panes/bevy_3d_viewport/Cargo.toml +++ b/bevy_editor_panes/bevy_3d_viewport/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bevy_3d_viewport" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] bevy.workspace = true @@ -10,6 +10,7 @@ bevy_editor_cam.workspace = true bevy_editor_styles.workspace = true bevy_infinite_grid.workspace = true bevy_editor_core.workspace = true +bevy_transform_gizmos.workspace = true [lints] workspace = true diff --git a/bevy_editor_panes/bevy_3d_viewport/src/lib.rs b/bevy_editor_panes/bevy_3d_viewport/src/lib.rs index 1a9e2760..702df596 100644 --- a/bevy_editor_panes/bevy_3d_viewport/src/lib.rs +++ b/bevy_editor_panes/bevy_3d_viewport/src/lib.rs @@ -1,26 +1,28 @@ //! 3D Viewport for Bevy use bevy::{ + asset::uuid::Uuid, + camera::{NormalizedRenderTarget, RenderTarget, visibility::RenderLayers}, + feathers::theme::ThemedText, picking::{ - pointer::{Location, PointerId, PointerInput, PointerLocation}, PickingSystems, + input::{mouse_pick_events, touch_pick_events}, + pointer::{Location, PointerId, PointerInput}, }, prelude::*, - render::{ - camera::{NormalizedRenderTarget, RenderTarget}, - render_resource::{Extent3d, TextureFormat, TextureUsages}, - view::RenderLayers, - }, + render::render_resource::{Extent3d, TextureFormat, TextureUsages}, + scene2::{CommandsSpawnScene, bsn, on}, ui::ui_layout_system, }; use bevy_editor_cam::prelude::{DefaultEditorCamPlugins, EditorCam}; use bevy_editor_styles::Theme; use bevy_infinite_grid::{InfiniteGrid, InfiniteGridPlugin, InfiniteGridSettings}; use bevy_pane_layout::prelude::*; -use view_gizmo::{spawn_view_gizmo_target_texture, ViewGizmoPlugin}; +use bevy_transform_gizmos::{TransformGizmo, prelude::*}; +use view_gizmo::ViewGizmoPlugin; -use crate::outline_gizmo::OutlineGizmoPlugin; +use crate::{selection_box::SelectionBoxPlugin, view_gizmo::view_gizmo_node}; -mod outline_gizmo; +mod selection_box; mod view_gizmo; /// The identifier for the 3D Viewport. @@ -47,15 +49,21 @@ impl Plugin for Viewport3dPanePlugin { app.add_plugins(InfiniteGridPlugin); } - app.add_plugins((DefaultEditorCamPlugins, ViewGizmoPlugin, OutlineGizmoPlugin)) + app.add_plugins((DefaultEditorCamPlugins, ViewGizmoPlugin, SelectionBoxPlugin)) .add_systems(Startup, setup) .add_systems( - PreUpdate, - render_target_picking_passthrough.in_set(PickingSystems::Last), + First, + render_target_picking_passthrough + .in_set(PickingSystems::Input) + .after(touch_pick_events) + .after(mouse_pick_events), ) .add_systems( PostUpdate, - update_render_target_size.after(ui_layout_system), + ( + update_render_target_size.after(ui_layout_system), + disable_editor_cam_during_gizmo_interaction, + ), ) .add_observer( |trigger: On, @@ -63,7 +71,7 @@ impl Plugin for Viewport3dPanePlugin { query: Query<&Bevy3dViewport>| { // Despawn the viewport camera commands - .entity(query.get(trigger.target()).unwrap().camera_id) + .entity(query.get(trigger.event().event_target()).unwrap().camera_id) .despawn(); }, ); @@ -72,59 +80,64 @@ impl Plugin for Viewport3dPanePlugin { } } +/// Temporary. We will need a proper design for mutually exclusive controls. +fn disable_editor_cam_during_gizmo_interaction( + transform_gizmo: Single>, + mut query: Query<&mut EditorCam>, +) { + if !transform_gizmo.is_changed() { + return; + } + let enable = transform_gizmo.interaction().is_none(); + for mut editor_cam in &mut query { + editor_cam.enabled = enable; + } +} + +/// A viewport is considered active while the mouse is hovering over it. #[derive(Component)] struct Active; -// TODO This does not properly handle multiple windows. -/// Copies picking events and moves pointers through render-targets. +// FIXME: This system makes a lot of assumptions and is therefore rather fragile. Does not handle multiple windows. +/// Sends copies of [`PointerInput`] event actions from the mouse pointer to pointers belonging to the viewport panes. fn render_target_picking_passthrough( - mut commands: Commands, viewports: Query<(Entity, &Bevy3dViewport)>, content: Query<&PaneContentNode>, children_query: Query<&Children>, node_query: Query<(&ComputedNode, &UiGlobalTransform, &ImageNode), With>, - mut pointers: Query<(&PointerId, &mut PointerLocation)>, - mut pointer_input_reader: EventReader, + mut pointer_input_reader: MessageReader, + // Using commands to output PointerInput events to avoid clashing with the MessageReader + mut commands: Commands, ) { for event in pointer_input_reader.read() { - // Ignore the events we send to the render-targets - if !matches!(event.location.target, NormalizedRenderTarget::Window(..)) { + // Ignore the events sent from this system by only copying events that come directly from the mouse. + if event.pointer_id != PointerId::Mouse { continue; } for (pane_root, _viewport) in &viewports { - let content_node_id = children_query + let Some(content_node_id) = children_query .iter_descendants(pane_root) .find(|e| content.contains(*e)) - .unwrap(); + else { + continue; + }; let image_id = children_query.get(content_node_id).unwrap()[0]; - let Ok((computed_node, global_transform, ui_image)) = node_query.get(image_id) else { // Inactive viewport continue; }; - let node_rect = - Rect::from_center_size(global_transform.translation, computed_node.size()); + let node_top_left = global_transform.translation - computed_node.size() / 2.; + let position = event.location.position - node_top_left; + let target = NormalizedRenderTarget::Image(ui_image.image.clone().into()); - let new_location = Location { - position: event.location.position - node_rect.min, - target: NormalizedRenderTarget::Image(ui_image.image.clone().into()), + let event_copy = PointerInput { + action: event.action, + location: Location { position, target }, + pointer_id: pointer_id_from_entity(pane_root), }; - // Duplicate the event - let mut new_event = event.clone(); - // Relocate the event to the render-target - new_event.location = new_location.clone(); - // Resend the event - commands.send_event(new_event); - - if let Some((_id, mut pointer_location)) = pointers - .iter_mut() - .find(|(pointer_id, _)| **pointer_id == event.pointer_id) - { - // Relocate the pointer to the render-target - pointer_location.location = Some(new_location); - } + commands.write_message(event_copy); } } } @@ -143,6 +156,12 @@ fn setup(mut commands: Commands, theme: Res) { )); } +/// Construct a pointer id from an entity. Used to tie the viewport panel root entity to a pointer id. +fn pointer_id_from_entity(entity: Entity) -> PointerId { + let bits = entity.to_bits(); + PointerId::Custom(Uuid::from_u64_pair(bits, bits)) +} + fn on_pane_creation( structure: In, mut commands: Commands, @@ -156,28 +175,34 @@ fn on_pane_creation( let image_handle = images.add(image); + // Spawn the cursor associated with this viewport pane. + let pointer_id = pointer_id_from_entity(structure.root); + commands.spawn((pointer_id, ChildOf(structure.root))); + + // Remove the existing structure + commands.entity(structure.area).despawn(); + + let image = image_handle.clone(); commands - .spawn(( - ImageNode::new(image_handle.clone()), - Node { - position_type: PositionType::Absolute, - top: Val::ZERO, - bottom: Val::ZERO, - left: Val::ZERO, - right: Val::ZERO, - ..default() - }, - ChildOf(structure.content), - )) - .with_children(|parent| { - spawn_view_gizmo_target_texture(images, parent); - }) - .observe(|trigger: On>, mut commands: Commands| { - commands.entity(trigger.target()).insert(Active); + .spawn_scene(bsn! { + :editor_pane [ + :editor_pane_header [ + (Text("3D Viewport") ThemedText), + ], + :editor_pane_body [ + ImageNode::new(image.clone()) + :fit_to_parent + on(|trigger: On>, mut commands: Commands| { + commands.entity(trigger.event().event_target()).insert(Active); + }) + on(|trigger: On>, mut commands: Commands| { + commands.entity(trigger.event().event_target()).remove::(); + }) + [ :view_gizmo_node ] + ], + ] }) - .observe(|trigger: On>, mut commands: Commands| { - commands.entity(trigger.target()).remove::(); - }); + .insert(ChildOf(structure.root)); let camera_id = commands .spawn(( @@ -188,8 +213,10 @@ fn on_pane_creation( ..default() }, EditorCam::default(), + GizmoCamera, Transform::from_translation(Vec3::ONE * 5.).looking_at(Vec3::ZERO, Vec3::Y), RenderLayers::from_layers(&[0, 1]), + MeshPickingCamera, )) .id(); @@ -201,18 +228,20 @@ fn on_pane_creation( fn update_render_target_size( query: Query<(Entity, &Bevy3dViewport)>, mut camera_query: Query<&Camera>, - content: Query<&PaneContentNode>, + bodies: Query<&PaneContentNode>, children_query: Query<&Children>, computed_node_query: Query<&ComputedNode, Changed>, mut images: ResMut>, ) { for (pane_root, viewport) in &query { - let content_node_id = children_query + let Some(pane_body) = children_query .iter_descendants(pane_root) - .find(|e| content.contains(*e)) - .unwrap(); + .find(|e| bodies.contains(*e)) + else { + continue; + }; - let Ok(computed_node) = computed_node_query.get(content_node_id) else { + let Ok(computed_node) = computed_node_query.get(pane_body) else { continue; }; // TODO Convert to physical pixels diff --git a/bevy_editor_panes/bevy_3d_viewport/src/outline_gizmo.rs b/bevy_editor_panes/bevy_3d_viewport/src/outline_gizmo.rs deleted file mode 100644 index 824a55c6..00000000 --- a/bevy_editor_panes/bevy_3d_viewport/src/outline_gizmo.rs +++ /dev/null @@ -1,81 +0,0 @@ -use bevy::prelude::*; -use bevy_editor_core::SelectedEntity; - -pub struct OutlineGizmoPlugin; -impl Plugin for OutlineGizmoPlugin { - fn build(&self, app: &mut App) { - app.init_resource::() - .add_systems(Startup, spawn_gizmo_toggle_ui) - .add_systems(Update, outline_gizmo_system) - .add_systems(Update, update_gizmo_toggle_text); - } -} - -#[derive(Resource, Default)] -pub struct ShowOutlines(pub bool); - -// Marker for the toggle button text -#[derive(Component)] -struct GizmoToggleText; - -pub fn outline_gizmo_system( - show: Res, - query: Query<&Transform>, - selected_entity: Res, - mut gizmos: Gizmos, -) { - if !show.0 { - return; - } - if let Some(entity) = selected_entity.0 { - if let Ok(transform) = query.get(entity) { - gizmos.cuboid(*transform, Color::srgb(1.0, 0.0, 0.0)); - } - } -} - -pub fn spawn_gizmo_toggle_ui(mut commands: Commands) { - info!("Spawning Gizmo Toggle UI"); - commands - .spawn(( - Node { - position_type: PositionType::Absolute, - top: Val::Px(20.0), - right: Val::Px(20.0), - width: Val::Px(100.0), - height: Val::Px(15.0), - align_items: AlignItems::Center, - justify_content: JustifyContent::Center, - ..default() - }, - BackgroundColor(Color::srgb(0.2, 0.2, 0.2)), - )) - .with_children(|parent| { - parent.spawn(( - Text::new("Show Outlines"), - TextFont::from_font_size(10.0), - GizmoToggleText, - )); - }) - .observe( - |_trigger: On>, mut show_outlines: ResMut| { - show_outlines.0 = !show_outlines.0; - }, - ); -} - -// System to update the button text when ShowOutlines changes -fn update_gizmo_toggle_text( - show_outlines: Res, - mut query: Query<&mut Text, With>, -) { - if show_outlines.is_changed() { - for mut text in &mut query { - text.0 = if show_outlines.0 { - "Hide Outlines".into() - } else { - "Show Outlines".into() - }; - } - } -} diff --git a/bevy_editor_panes/bevy_3d_viewport/src/selection_box.rs b/bevy_editor_panes/bevy_3d_viewport/src/selection_box.rs new file mode 100644 index 00000000..8f0f2e8e --- /dev/null +++ b/bevy_editor_panes/bevy_3d_viewport/src/selection_box.rs @@ -0,0 +1,283 @@ +use bevy::camera::primitives::Aabb; +use bevy::ecs::system::SystemParam; +use bevy::prelude::*; +use bevy_editor_core::selection::EditorSelection; + +#[derive(SystemParam)] +pub struct SelectionBoxQueries<'w, 's> { + pub mesh_query: Query< + 'w, + 's, + ( + &'static GlobalTransform, + &'static Mesh3d, + Option<&'static Aabb>, + ), + >, + pub sprite_query: Query<'w, 's, (&'static GlobalTransform, &'static Sprite), Without>, + pub aabb_query: Query< + 'w, + 's, + (&'static GlobalTransform, &'static Aabb), + (Without, Without), + >, + pub children_query: Query<'w, 's, &'static Children>, + pub transform_query: + Query<'w, 's, &'static GlobalTransform, (Without, Without, Without)>, +} + +pub struct SelectionBoxPlugin; +impl Plugin for SelectionBoxPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Startup, spawn_selection_box_toggle_ui) + .add_systems(Update, selection_box_system) + .add_systems(Update, update_selection_box_toggle_text); + } +} + +#[derive(Resource, Default)] +pub struct ShowSelectionBox(pub bool); + +// Marker for the toggle button text +#[derive(Component)] +struct SelectionBoxToggleText; + +/// Draw an outline for a world-space AABB +fn draw_selection_box(gizmos: &mut Gizmos, aabb: &Aabb) { + let min = aabb.min(); + let max = aabb.max(); + let center = (min + max) * 0.5; + let size = max - min; + + // Draw the cuboid outline at the AABB center with the AABB size + let outline_transform = Transform::from_translation(center.into()).with_scale(size.into()); + + gizmos.cuboid(outline_transform, Color::srgb(1.0, 0.5, 0.0)); // Orange outline +} + +/// Fallback outline for entities without proper bounds +fn draw_fallback_selection_box(gizmos: &mut Gizmos, global_transform: &GlobalTransform) { + let translation = global_transform.translation(); + let default_size = Vec3::splat(1.0); + + let outline_transform = Transform::from_translation(translation).with_scale(default_size); + + gizmos.cuboid(outline_transform, Color::srgb(0.5, 0.5, 0.5)); // Gray outline +} + +pub fn selection_box_system( + show: Res, + selection: Res, + mut gizmos: Gizmos, + queries: SelectionBoxQueries, + meshes: Res>, +) { + if !show.0 { + return; + } + + for entity in selection.iter() { + // Calculate the bounding box for the entity (including children) + if let Some(world_aabb) = calculate_world_aabb( + entity, + &queries.mesh_query, + &queries.sprite_query, + &queries.aabb_query, + &queries.children_query, + &queries.transform_query, + &meshes, + ) { + draw_selection_box(&mut gizmos, &world_aabb); + } else { + // Fallback to simple transform-based selection box + if let Ok(global_transform) = queries.transform_query.get(entity) { + draw_fallback_selection_box(&mut gizmos, global_transform); + } + } + } +} + +/// Calculate the world-space AABB for an entity and optionally its children +fn calculate_world_aabb( + entity: Entity, + mesh_query: &Query<(&GlobalTransform, &Mesh3d, Option<&Aabb>)>, + sprite_query: &Query<(&GlobalTransform, &Sprite), Without>, + aabb_query: &Query<(&GlobalTransform, &Aabb), (Without, Without)>, + children_query: &Query<&Children>, + transform_query: &Query<&GlobalTransform, (Without, Without, Without)>, + meshes: &Assets, +) -> Option { + let mut combined_aabb: Option = None; + + // Helper function to combine AABBs + let mut combine_aabb = |new_aabb: Aabb| { + if let Some(existing) = combined_aabb { + combined_aabb = Some(combine_aabbs(&existing, &new_aabb)); + } else { + combined_aabb = Some(new_aabb); + } + }; + + // Try to get AABB from the entity itself + if let Some(entity_aabb) = get_entity_aabb( + entity, + mesh_query, + sprite_query, + aabb_query, + transform_query, + meshes, + ) { + combine_aabb(entity_aabb); + } + + // Recursively include children's AABBs + if let Ok(children) = children_query.get(entity) { + for &child in children { + if let Some(child_aabb) = calculate_world_aabb( + child, + mesh_query, + sprite_query, + aabb_query, + children_query, + transform_query, + meshes, + ) { + combine_aabb(child_aabb); + } + } + } + + combined_aabb +} + +/// Combine two AABBs into a single AABB that encompasses both +fn combine_aabbs(a: &Aabb, b: &Aabb) -> Aabb { + let min = a.min().min(b.min()); + let max = a.max().max(b.max()); + Aabb::from_min_max(min.into(), max.into()) +} + +/// Get the AABB for a single entity +fn get_entity_aabb( + entity: Entity, + mesh_query: &Query<(&GlobalTransform, &Mesh3d, Option<&Aabb>)>, + sprite_query: &Query<(&GlobalTransform, &Sprite), Without>, + aabb_query: &Query<(&GlobalTransform, &Aabb), (Without, Without)>, + transform_query: &Query<&GlobalTransform, (Without, Without, Without)>, + meshes: &Assets, +) -> Option { + // Try mesh entities first + if let Ok((global_transform, mesh_handle, existing_aabb)) = mesh_query.get(entity) { + // Use existing AABB if available, otherwise compute from mesh + let local_aabb = if let Some(aabb) = existing_aabb { + *aabb + } else if let Some(_mesh) = meshes.get(&mesh_handle.0) { + // TODO: Compute AABB from mesh if possible + Aabb::from_min_max(Vec3::splat(-0.5), Vec3::splat(0.5)) + } else { + return None; + }; + + return Some(transform_aabb(&local_aabb, global_transform)); + } + + // Try sprite entities + if let Ok((global_transform, sprite)) = sprite_query.get(entity) { + let size = sprite.custom_size.unwrap_or(Vec2::new(1.0, 1.0)); + let local_aabb = Aabb::from_min_max( + Vec3::new(-size.x * 0.5, -size.y * 0.5, -0.01), + Vec3::new(size.x * 0.5, size.y * 0.5, 0.01), + ); + return Some(transform_aabb(&local_aabb, global_transform)); + } + + // Try entities with existing AABB components + if let Ok((global_transform, aabb)) = aabb_query.get(entity) { + return Some(transform_aabb(aabb, global_transform)); + } + + // Fallback for entities with just transforms + if let Ok(global_transform) = transform_query.get(entity) { + let default_size = 0.5; + let local_aabb = Aabb::from_min_max(Vec3::splat(-default_size), Vec3::splat(default_size)); + return Some(transform_aabb(&local_aabb, global_transform)); + } + + None +} + +/// Transform a local AABB to world space using `GlobalTransform` +fn transform_aabb(local_aabb: &Aabb, global_transform: &GlobalTransform) -> Aabb { + // Get the 8 corners of the AABB + let min = local_aabb.min(); + let max = local_aabb.max(); + let corners = [ + Vec3::new(min.x, min.y, min.z), + Vec3::new(max.x, min.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(min.x, min.y, max.z), + Vec3::new(max.x, min.y, max.z), + Vec3::new(min.x, max.y, max.z), + Vec3::new(max.x, max.y, max.z), + ] + .map(|corner| global_transform.transform_point(corner)); + + // Find the min/max of transformed corners + let mut world_min = corners[0]; + let mut world_max = corners[0]; + + for &corner in &corners[1..] { + world_min = world_min.min(corner); + world_max = world_max.max(corner); + } + + Aabb::from_min_max(world_min, world_max) +} + +pub fn spawn_selection_box_toggle_ui(mut commands: Commands) { + info!("Spawning Selection Box Toggle UI"); + commands + .spawn(( + Node { + position_type: PositionType::Absolute, + top: Val::Px(20.0), + right: Val::Px(20.0), + width: Val::Px(100.0), + height: Val::Px(15.0), + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..default() + }, + BackgroundColor(Color::srgb(0.2, 0.2, 0.2)), + )) + .with_children(|parent| { + parent.spawn(( + Text::new("Show Selection Box"), + TextFont::from_font_size(10.0), + SelectionBoxToggleText, + )); + }) + .observe( + |_trigger: On>, mut show_selection: ResMut| { + show_selection.0 = !show_selection.0; + }, + ); +} + +// System to update the button text when ShowSelectionBox changes +fn update_selection_box_toggle_text( + show_selection: Res, + mut query: Query<&mut Text, With>, +) { + if show_selection.is_changed() { + for mut text in &mut query { + text.0 = if show_selection.0 { + "Hide Selection Box".into() + } else { + "Show Selection Box".into() + }; + } + } +} diff --git a/bevy_editor_panes/bevy_3d_viewport/src/view_gizmo.rs b/bevy_editor_panes/bevy_3d_viewport/src/view_gizmo.rs index 0f8d3324..722678ae 100644 --- a/bevy_editor_panes/bevy_3d_viewport/src/view_gizmo.rs +++ b/bevy_editor_panes/bevy_3d_viewport/src/view_gizmo.rs @@ -1,166 +1,165 @@ -//! This module sets up a simple way to spawn a view gizmo that indicates the 3 main axis -//! It's currently hard coded to the top left of the parent `UiNode` it's spawned in. -//! It currently doesn't support any input event to move the camera based on a click. - -use bevy::{ - asset::RenderAssetUsages, - ecs::relationship::RelatedSpawnerCommands, - prelude::*, - render::{ - render_resource::{Extent3d, Face, TextureDimension, TextureFormat, TextureUsages}, - view::RenderLayers, - }, -}; -use bevy_editor_cam::prelude::EditorCam; - -// That value was picked arbitrarily -pub const VIEW_GIZMO_TEXTURE_SIZE: u32 = 125; -// TODO we really shouldn't just hardcode view layers like that -pub const VIEW_GIZMO_LAYER: usize = 22; - -const GIZMO_CAMERA_ZOOM: f32 = 3.5; - -pub struct ViewGizmoPlugin; -impl Plugin for ViewGizmoPlugin { - fn build(&self, app: &mut App) { - app.add_systems(Startup, setup_view_gizmo) - .add_systems(Update, (spawn_view_gizmo_camera, update_view_gizmo)); - } -} - -#[derive(Component)] -pub struct ViewGizmoCamera; - -#[derive(Component)] -pub struct ViewGizmoCameraTarget(pub Handle); - -pub fn spawn_view_gizmo_target_texture( - mut images: ResMut<'_, Assets>, - parent: &mut RelatedSpawnerCommands, -) { - let size = Extent3d { - width: VIEW_GIZMO_TEXTURE_SIZE, - height: VIEW_GIZMO_TEXTURE_SIZE, - ..default() - }; - - let mut target_texture = Image::new_fill( - size, - TextureDimension::D2, - &[0, 0, 0, 0], - TextureFormat::Bgra8UnormSrgb, - RenderAssetUsages::default(), - ); - target_texture.texture_descriptor.usage = - TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT; - - let image = images.add(target_texture); - - // TODO don't hardcode it to top left - // TODO send input events to the image target - parent.spawn(( - ImageNode::new(image.clone()), - Node { - position_type: PositionType::Absolute, - top: Val::ZERO, - bottom: Val::ZERO, - left: Val::ZERO, - right: Val::ZERO, - width: Val::Px(VIEW_GIZMO_TEXTURE_SIZE as f32), - height: Val::Px(VIEW_GIZMO_TEXTURE_SIZE as f32), - ..default() - }, - ViewGizmoCameraTarget(image.clone()), - )); -} - -fn setup_view_gizmo( - mut commands: Commands, - mut meshes: ResMut>, - mut materials: ResMut>, - mut gizmo_assets: ResMut>, -) { - info!("Spawning View Gizmo"); - let view_gizmo_pass_layer = RenderLayers::layer(VIEW_GIZMO_LAYER); - let sphere = meshes.add(Sphere::new(0.2).mesh().uv(32, 18)); - - for axis in [ - Vec3::new(1.0, 0.0, 0.0), - Vec3::new(0.0, 1.0, 0.0), - Vec3::new(0.0, 0.0, 1.0), - ] { - let mut gizmo = GizmoAsset::new(); - let color = LinearRgba::from_vec3(axis); - gizmo.line(Vec3::ZERO, axis, color); - commands.spawn(( - Gizmo { - handle: gizmo_assets.add(gizmo), - line_config: GizmoLineConfig { - width: 2.5, - ..default() - }, - ..default() - }, - Transform::from_xyz(0., 0., 0.), - view_gizmo_pass_layer.clone(), - )); - // TODO react to click on the spheres to snap camera to axis - commands.spawn(( - Mesh3d(sphere.clone()), - MeshMaterial3d(materials.add(StandardMaterial { - base_color: color.into(), - unlit: true, - ..Default::default() - })), - Transform::from_translation(axis), - view_gizmo_pass_layer.clone(), - )); - } - // Use a sphere for the background - let sphere = meshes.add(Sphere::new(1.3).mesh().uv(32, 18)); - commands.spawn(( - Mesh3d(sphere.clone()), - MeshMaterial3d(materials.add(StandardMaterial { - base_color: LinearRgba::new(0.0, 0.0, 0.0, 0.5).into(), - unlit: true, - // reverse cull mode so it appears behind - cull_mode: Some(Face::Front), - alpha_mode: AlphaMode::Blend, - ..Default::default() - })), - Transform::from_xyz(0.0, 0.0, 0.0), - view_gizmo_pass_layer.clone(), - )); -} - -fn spawn_view_gizmo_camera( - mut commands: Commands, - q: Query<&ViewGizmoCameraTarget, Added>, -) { - let view_gizmo_pass_layer = RenderLayers::layer(VIEW_GIZMO_LAYER); - for target in &q { - commands.spawn(( - Camera3d::default(), - Camera { - target: target.0.clone().into(), - clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 0.0)), - ..default() - }, - Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)).looking_at(Vec3::ZERO, Vec3::Y), - view_gizmo_pass_layer.clone(), - ViewGizmoCamera, - )); - } -} - -fn update_view_gizmo( - mut view_cube_camera: Query<&mut Transform, (With, With)>, - viewport_camera: Query<&Transform, (Without, With, With)>, -) { - for mut transform in &mut view_cube_camera { - if let Ok(viewport_camera_transform) = viewport_camera.single() { - transform.translation = viewport_camera_transform.back() * GIZMO_CAMERA_ZOOM; - transform.rotation = viewport_camera_transform.rotation; - } - } -} +//! This module sets up a simple way to spawn a view gizmo that indicates the 3 main axis +//! It's currently hard coded to the top left of the parent `UiNode` it's spawned in. +//! It currently doesn't support any input event to move the camera based on a click. + +use bevy::{ + asset::RenderAssetUsages, + camera::visibility::RenderLayers, + ecs::template::template, + prelude::*, + render::render_resource::{Extent3d, Face, TextureDimension, TextureFormat, TextureUsages}, + scene2::{Scene, bsn}, +}; +use bevy_editor_cam::prelude::EditorCam; +use bevy_editor_styles::Theme; +use bevy_pane_layout::components::fit_to_parent; + +// That value was picked arbitrarily +pub const VIEW_GIZMO_TEXTURE_SIZE: u32 = 125; +// TODO we really shouldn't just hardcode view layers like that +pub const VIEW_GIZMO_LAYER: usize = 22; + +const GIZMO_CAMERA_ZOOM: f32 = 3.5; + +pub struct ViewGizmoPlugin; +impl Plugin for ViewGizmoPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Startup, setup_view_gizmo) + .add_systems(Update, (spawn_view_gizmo_camera, update_view_gizmo)); + } +} + +#[derive(Component)] +pub struct ViewGizmoCamera; + +#[derive(Component)] +pub struct ViewGizmoCameraTarget(pub Handle); + +pub fn view_gizmo_node() -> impl Scene { + bsn! { + :fit_to_parent + Node { + width: Val::Px({VIEW_GIZMO_TEXTURE_SIZE as f32}), + height: Val::Px({VIEW_GIZMO_TEXTURE_SIZE as f32}), + } + template(|c| view_gizmo_template(c.entity)) + } +} + +fn view_gizmo_template(entity: &mut EntityWorldMut) -> Result<()> { + let size = Extent3d { + width: VIEW_GIZMO_TEXTURE_SIZE, + height: VIEW_GIZMO_TEXTURE_SIZE, + ..default() + }; + + let mut target_texture = Image::new_fill( + size, + TextureDimension::D2, + &[0, 0, 0, 0], + TextureFormat::Bgra8UnormSrgb, + RenderAssetUsages::default(), + ); + target_texture.texture_descriptor.usage = + TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::RENDER_ATTACHMENT; + + let image = entity.resource_mut::>().add(target_texture); + + entity.insert(( + ViewGizmoCameraTarget(image.clone()), + ImageNode::new(image.clone()), + )); + + Ok(()) +} + +fn setup_view_gizmo( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, + mut gizmo_assets: ResMut>, + theme: Res, +) { + info!("Spawning View Gizmo"); + let view_gizmo_pass_layer = RenderLayers::layer(VIEW_GIZMO_LAYER); + let sphere = meshes.add(Sphere::new(0.2).mesh().uv(32, 18)); + + for (axis, color) in [ + (Vec3::X, theme.viewport.x_axis_color), + (Vec3::Y, theme.viewport.y_axis_color), + (Vec3::Z, theme.viewport.z_axis_color), + ] { + let mut gizmo = GizmoAsset::new(); + gizmo.line(Vec3::ZERO, axis, color); + commands.spawn(( + Gizmo { + handle: gizmo_assets.add(gizmo), + line_config: GizmoLineConfig { + width: 2.5, + ..default() + }, + ..default() + }, + Transform::from_xyz(0., 0., 0.), + view_gizmo_pass_layer.clone(), + )); + // TODO react to click on the spheres to snap camera to axis + commands.spawn(( + Mesh3d(sphere.clone()), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: color, + unlit: true, + ..Default::default() + })), + Transform::from_translation(axis), + view_gizmo_pass_layer.clone(), + )); + } + // Use a sphere for the background + let sphere = meshes.add(Sphere::new(1.3).mesh().uv(32, 18)); + commands.spawn(( + Mesh3d(sphere.clone()), + MeshMaterial3d(materials.add(StandardMaterial { + base_color: LinearRgba::new(0.0, 0.0, 0.0, 0.5).into(), + unlit: true, + // reverse cull mode so it appears behind + cull_mode: Some(Face::Front), + alpha_mode: AlphaMode::Blend, + ..Default::default() + })), + Transform::from_xyz(0.0, 0.0, 0.0), + view_gizmo_pass_layer.clone(), + )); +} + +fn spawn_view_gizmo_camera( + mut commands: Commands, + q: Query<&ViewGizmoCameraTarget, Added>, +) { + let view_gizmo_pass_layer = RenderLayers::layer(VIEW_GIZMO_LAYER); + for target in &q { + commands.spawn(( + Camera3d::default(), + Camera { + target: target.0.clone().into(), + clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 0.0)), + ..default() + }, + Transform::from_translation(Vec3::new(0.0, 0.0, 0.0)).looking_at(Vec3::ZERO, Vec3::Y), + view_gizmo_pass_layer.clone(), + ViewGizmoCamera, + )); + } +} + +fn update_view_gizmo( + mut view_cube_camera: Query<&mut Transform, (With, With)>, + viewport_camera: Query<&Transform, (Without, With, With)>, +) { + for mut transform in &mut view_cube_camera { + if let Ok(viewport_camera_transform) = viewport_camera.single() { + transform.translation = viewport_camera_transform.back() * GIZMO_CAMERA_ZOOM; + transform.rotation = viewport_camera_transform.rotation; + } + } +} diff --git a/bevy_editor_panes/bevy_asset_browser/Cargo.toml b/bevy_editor_panes/bevy_asset_browser/Cargo.toml index f6d0c58f..872a3dfe 100644 --- a/bevy_editor_panes/bevy_asset_browser/Cargo.toml +++ b/bevy_editor_panes/bevy_asset_browser/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bevy_asset_browser" version = "0.1.0" -edition = "2021" +edition = "2024" [features] diff --git a/bevy_editor_panes/bevy_asset_browser/src/io/task.rs b/bevy_editor_panes/bevy_asset_browser/src/io/task.rs index c5daa48a..0a6f04d6 100644 --- a/bevy_editor_panes/bevy_asset_browser/src/io/task.rs +++ b/bevy_editor_panes/bevy_asset_browser/src/io/task.rs @@ -1,8 +1,8 @@ -use crate::{AssetBrowserLocation, DirectoryContent, Entry}; +use crate::{AssetBrowserLocation, DirectoryContent, DirectoryContentOrder, Entry}; use bevy::{ asset::io::AssetSourceBuilders, prelude::*, - tasks::{block_on, futures_lite::StreamExt, poll_once, IoTaskPool, Task}, + tasks::{IoTaskPool, Task, block_on, futures_lite::StreamExt, poll_once}, }; #[derive(Component)] @@ -20,9 +20,12 @@ pub(crate) fn fetch_task_is_running( pub(crate) fn poll_task( mut commands: Commands, mut task_query: Query<(Entity, &mut FetchDirectoryContentTask)>, + content_order: Res, ) { let (task_entity, mut task) = task_query.single_mut().unwrap(); - if let Some(content) = block_on(poll_once(&mut task.0)) { + if let Some(mut content) = block_on(poll_once(&mut task.0)) { + content_order.sort(&mut content); + commands.entity(task_entity).despawn(); commands.insert_resource(content); } diff --git a/bevy_editor_panes/bevy_asset_browser/src/lib.rs b/bevy_editor_panes/bevy_asset_browser/src/lib.rs index e404c82c..ebb770c6 100644 --- a/bevy_editor_panes/bevy_asset_browser/src/lib.rs +++ b/bevy_editor_panes/bevy_asset_browser/src/lib.rs @@ -1,13 +1,12 @@ //! 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; +use std::{cmp::Ordering, path::PathBuf}; use bevy::{ asset::{ - embedded_asset, - io::{file::FileAssetReader, AssetSourceId}, - AssetPlugin, + AssetPlugin, embedded_asset, + io::{AssetSourceId, file::FileAssetReader}, }, prelude::*, }; @@ -48,6 +47,8 @@ impl Plugin for AssetBrowserPanePlugin { .insert_resource(DefaultSourceFilePath(default_source_absolute_file_path)) .insert_resource(AssetBrowserLocation::default()) .insert_resource(DirectoryContent::default()) + .insert_resource(DirectoryContentOrder::ReverseAlphabetical) + // .init_resource::() .add_systems(Startup, io::task::fetch_directory_content) // .add_systems(Update, button_interaction) .add_systems( @@ -71,6 +72,47 @@ impl Plugin for AssetBrowserPanePlugin { } } +fn alphabetical_sort(left: &Entry, right: &Entry) -> Ordering { + match (left, right) { + (Entry::Folder(left_name), Entry::Folder(right_name)) + | (Entry::File(left_name), Entry::File(right_name)) => left_name.cmp(right_name), + (Entry::File(_), Entry::Folder(_)) => Ordering::Greater, + (Entry::Folder(_), Entry::File(_)) => Ordering::Less, + // TODO: Figure out whether or not ignoring the order of asset sources is a good idea. + _ => Ordering::Equal, + } +} + +fn reverse_alphabetical_sort(left: &Entry, right: &Entry) -> Ordering { + match (left, right) { + (Entry::Folder(left_name), Entry::Folder(right_name)) + | (Entry::File(left_name), Entry::File(right_name)) => left_name.cmp(right_name).reverse(), + (Entry::File(_), Entry::Folder(_)) => Ordering::Greater, + (Entry::Folder(_), Entry::File(_)) => Ordering::Less, + // TODO: Figure out whether or not ignoring the order of asset sources is a good idea. + _ => Ordering::Equal, + } +} + +/// How [`DirectoryContent`] should be ordered +#[derive(Resource, Default, Debug, Clone, PartialEq, Eq)] +pub enum DirectoryContentOrder { + /// Ordered alphabetically with respect to folders + #[default] + Alphabetical, + /// Ordered reverse alphabetically with respect to folders + ReverseAlphabetical, +} +impl DirectoryContentOrder { + /// Sorts a given [`DirectoryContent`] with the current method + pub fn sort(&self, content: &mut DirectoryContent) { + match self { + Self::Alphabetical => content.0.sort_by(alphabetical_sort), + Self::ReverseAlphabetical => content.0.sort_by(reverse_alphabetical_sort), + } + } +} + /// One entry of [`DirectoryContent`] #[derive(Debug, Clone, PartialEq, Eq)] pub enum Entry { diff --git a/bevy_editor_panes/bevy_asset_browser/src/ui/directory_content.rs b/bevy_editor_panes/bevy_asset_browser/src/ui/directory_content.rs index fba648e0..e9edc11f 100644 --- a/bevy_editor_panes/bevy_asset_browser/src/ui/directory_content.rs +++ b/bevy_editor_panes/bevy_asset_browser/src/ui/directory_content.rs @@ -1,9 +1,9 @@ use bevy::{asset::io::AssetSourceId, prelude::*}; use bevy_context_menu::{ContextMenu, ContextMenuOption}; use bevy_editor_styles::Theme; -use bevy_scroll_box::{spawn_scroll_box, ScrollBox, ScrollBoxContent}; +use bevy_scroll_box::{ScrollBox, ScrollBoxContent, spawn_scroll_box}; -use crate::{io, AssetBrowserLocation, DefaultSourceFilePath, DirectoryContent, Entry}; +use crate::{AssetBrowserLocation, DefaultSourceFilePath, DirectoryContent, Entry, io}; use crate::ui::nodes::{spawn_file_node, spawn_folder_node, spawn_source_node}; diff --git a/bevy_editor_panes/bevy_asset_browser/src/ui/nodes.rs b/bevy_editor_panes/bevy_asset_browser/src/ui/nodes.rs index 7d3ed385..ba7c8129 100644 --- a/bevy_editor_panes/bevy_asset_browser/src/ui/nodes.rs +++ b/bevy_editor_panes/bevy_asset_browser/src/ui/nodes.rs @@ -3,18 +3,18 @@ use atomicow::CowArc; use bevy::{ asset::io::{AssetSource, AssetSourceBuilders, AssetSourceId}, + feathers::cursor::EntityCursor, prelude::*, window::SystemCursorIcon, - winit::cursor::CursorIcon, }; use bevy_context_menu::{ContextMenu, ContextMenuOption}; use bevy_editor_styles::Theme; -use crate::{io, ui::source_id_to_string, AssetBrowserLocation}; +use crate::{AssetBrowserLocation, io, ui::source_id_to_string}; use super::{ - directory_content::{delete_file, delete_folder}, DEFAULT_SOURCE_ID_NAME, + directory_content::{delete_file, delete_folder}, }; pub(crate) fn spawn_source_node<'a>( @@ -34,7 +34,7 @@ pub(crate) fn spawn_source_node<'a>( if trigger.event().button != PointerButton::Primary { return; } - let button = trigger.target(); + let button = trigger.event().event_target(); let button_children = query_children.get(button).unwrap(); let source_name = &query_text .get(button_children[1]) @@ -99,7 +99,7 @@ pub(crate) fn spawn_folder_node<'a>( if trigger.event().button != PointerButton::Primary { return; } - let button = trigger.target(); + let button = trigger.event().event_target(); let button_children = query_children.get(button).unwrap(); let folder_name = &query_text .get(button_children[1]) @@ -200,7 +200,7 @@ pub(crate) fn spawn_file_node<'a>( } fn spawn_base_node<'a>(commands: &'a mut Commands, theme: &Res) -> EntityCommands<'a> { - let mut base_node_ec = commands.spawn(( + commands.spawn(( Button, Node { margin: UiRect::all(Val::Px(5.0)), @@ -215,30 +215,6 @@ fn spawn_base_node<'a>(commands: &'a mut Commands, theme: &Res) -> Entity }, ZIndex(1), theme.general.border_radius, - )); - - // Hover effect - base_node_ec - .observe( - move |_trigger: On>, - window_query: Query>, - mut commands: Commands| { - let window = window_query.single().unwrap(); - commands - .entity(window) - .insert(CursorIcon::System(SystemCursorIcon::Pointer)); - }, - ) - .observe( - move |_trigger: On>, - window_query: Query>, - mut commands: Commands| { - let window = window_query.single().unwrap(); - commands - .entity(window) - .insert(CursorIcon::System(SystemCursorIcon::Default)); - }, - ); - - base_node_ec + EntityCursor::System(SystemCursorIcon::Pointer), + )) } diff --git a/bevy_editor_panes/bevy_asset_browser/src/ui/top_bar.rs b/bevy_editor_panes/bevy_asset_browser/src/ui/top_bar.rs index ca619458..ddb5ddfe 100644 --- a/bevy_editor_panes/bevy_asset_browser/src/ui/top_bar.rs +++ b/bevy_editor_panes/bevy_asset_browser/src/ui/top_bar.rs @@ -1,7 +1,7 @@ -use bevy::{prelude::*, window::SystemCursorIcon, winit::cursor::CursorIcon}; +use bevy::{feathers::cursor::EntityCursor, prelude::*, window::SystemCursorIcon}; use bevy_editor_styles::Theme; -use crate::{io, AssetBrowserLocation}; +use crate::{AssetBrowserLocation, io}; use super::source_id_to_string; @@ -83,11 +83,10 @@ pub fn spawn_location_path_ui<'a>( ) .insert(ChildOf(location_path)); - if location.source_id.is_some() { + if let Some(source_id) = &location.source_id { commands .spawn(path_separator_ui(theme.as_ref())) .insert(ChildOf(location_path)); - let source_id = location.source_id.as_ref().unwrap(); spawn_path_segment_ui( commands, source_id_to_string(source_id), @@ -130,6 +129,7 @@ fn spawn_path_segment_ui<'a>( BackgroundColor(PATH_SEGMENT_BACKGROUND_COLOR), theme.general.border_radius, segment_type, + EntityCursor::System(SystemCursorIcon::Pointer), )); segment_ec .with_children(|parent| { @@ -149,7 +149,7 @@ fn spawn_path_segment_ui<'a>( mut location: ResMut, query_children: Query<&Children>, query_segment_info: Query<(&ChildOf, &LocationSegmentType)>| { - let segment = trigger.target(); + let segment = trigger.event().event_target(); let (parent, segment_type) = query_segment_info.get(segment).unwrap(); match segment_type { LocationSegmentType::Root => { @@ -178,26 +178,6 @@ fn spawn_path_segment_ui<'a>( }; commands.run_system_cached(io::task::fetch_directory_content); }, - ) - .observe( - move |_trigger: On>, - window_query: Query>, - mut commands: Commands| { - let window = window_query.single().unwrap(); - commands - .entity(window) - .insert(CursorIcon::System(SystemCursorIcon::Pointer)); - }, - ) - .observe( - move |_trigger: On>, - window_query: Query>, - mut commands: Commands| { - let window = window_query.single().unwrap(); - commands - .entity(window) - .insert(CursorIcon::System(SystemCursorIcon::Default)); - }, ); segment_ec } diff --git a/bevy_editor_panes/bevy_marketplace_viewer/Cargo.toml b/bevy_editor_panes/bevy_marketplace_viewer/Cargo.toml index 21572b72..2629d359 100644 --- a/bevy_editor_panes/bevy_marketplace_viewer/Cargo.toml +++ b/bevy_editor_panes/bevy_marketplace_viewer/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "bevy_marketplace_viewer" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] bevy.workspace = true [lints] -workspace = true \ No newline at end of file +workspace = true diff --git a/bevy_editor_panes/bevy_preferences/Cargo.toml b/bevy_editor_panes/bevy_preferences/Cargo.toml index d776a035..504811c4 100644 --- a/bevy_editor_panes/bevy_preferences/Cargo.toml +++ b/bevy_editor_panes/bevy_preferences/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bevy_preferences" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] bevy.workspace = true diff --git a/bevy_editor_panes/bevy_properties_pane/Cargo.toml b/bevy_editor_panes/bevy_properties_pane/Cargo.toml index 53ef79f3..de45a1de 100644 --- a/bevy_editor_panes/bevy_properties_pane/Cargo.toml +++ b/bevy_editor_panes/bevy_properties_pane/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bevy_properties_pane" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] bevy.workspace = true diff --git a/bevy_editor_panes/bevy_properties_pane/src/lib.rs b/bevy_editor_panes/bevy_properties_pane/src/lib.rs index 77c41470..a8d7fd45 100644 --- a/bevy_editor_panes/bevy_properties_pane/src/lib.rs +++ b/bevy_editor_panes/bevy_properties_pane/src/lib.rs @@ -2,76 +2,99 @@ //! //! Data can be viewed and modified in real-time, with changes being reflected in the application. -use bevy::{color::palettes::tailwind, prelude::*, reflect::*}; -use bevy_editor_core::SelectedEntity; -use bevy_i_cant_believe_its_not_bsn::{template, Template, TemplateEntityCommandsExt}; -use bevy_pane_layout::prelude::{PaneAppExt, PaneStructure}; +use bevy::{ + feathers::theme::ThemedText, + prelude::*, + reflect::*, + scene2::{CommandsSpawnScene, Scene, SceneList, bsn}, +}; +use bevy_editor_core::{prelude::*, selection::common_conditions::primary_selection_changed}; +use bevy_editor_styles::Theme; +use bevy_pane_layout::prelude::*; /// Plugin for the editor properties pane. pub struct PropertiesPanePlugin; impl Plugin for PropertiesPanePlugin { fn build(&self, app: &mut App) { - app.register_pane("Properties", setup_pane) - .add_systems(PostUpdate, update_properties_pane); + app.register_pane("Properties", setup_pane).add_systems( + Update, + (update_properties_pane.run_if( + primary_selection_changed.or(any_match_filter::>), + ),), + ); } } /// Root UI node of the properties pane. -#[derive(Component)] -struct PropertiesPaneRoot; +#[derive(Component, Default, Clone)] +struct PropertiesPaneBody; fn setup_pane(pane: In, mut commands: Commands) { - commands.entity(pane.content).insert(( - PropertiesPaneRoot, - Node { - flex_direction: FlexDirection::Column, - flex_grow: 1.0, - column_gap: Val::Px(4.0), - padding: UiRect::all(Val::Px(8.0)), - ..Default::default() - }, - BackgroundColor(tailwind::NEUTRAL_600.into()), - )); + // Remove the existing structure + commands.entity(pane.area).despawn(); + + commands + .spawn_scene(bsn! { + :editor_pane [ + :editor_pane_header [ + (Text("Properties") ThemedText), + ], + :editor_pane_body + PropertiesPaneBody + ] + }) + .insert(ChildOf(pane.root)); } fn update_properties_pane( - panes: Query>, - selected_entity: Res, + pane_bodies: Query>, + selection: Res, + theme: Res, world: &World, mut commands: Commands, ) { - for pane in &panes { + for pane_body in &pane_bodies { + commands.entity(pane_body).despawn_children(); commands - .entity(pane) - .build_children(properties_pane(&selected_entity, world)); + .spawn_scene(properties_pane(&selection, &theme, world)) + .insert(ChildOf(pane_body)); } } -fn properties_pane(selected_entity: &SelectedEntity, world: &World) -> Template { - match selected_entity.0 { - Some(selected_entity) => component_list(selected_entity, world), - None => template! { +fn properties_pane(selection: &EditorSelection, theme: &Theme, world: &World) -> impl Scene { + match selection.primary() { + Some(selection) => bsn! { Node { flex_direction: FlexDirection::Column, - ..Default::default() - } => [ - ( - Text("Select an entity to inspect".into()), - TextFont::from_font_size(14.0), - ); - ]; - - }, + padding: UiRect::all(Val::Px(8.0)), + row_gap: Val::Px(6.0) + } [ + {component_list(selection, theme, world)} + ]} + .boxed_scene(), + None => bsn! { + Node { + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + padding: UiRect::all(Val::Px(24.0)) + } [ + Text("Select an entity to inspect") + TextFont::from_font_size(14.0) + TextColor(Color::srgb(0.514, 0.514, 0.522)), + ] + } + .boxed_scene(), } } -fn component_list(entity: Entity, world: &World) -> Template { +fn component_list(entity: Entity, theme: &Theme, world: &World) -> impl SceneList { let type_registry = world.resource::().read(); world .inspect_entity(entity) .unwrap() - .flat_map(|component_info| { + .map(|component_info| { let type_info = component_info .type_id() .and_then(|type_id| type_registry.get_type_info(type_id)); @@ -88,131 +111,166 @@ fn component_list(entity: Entity, world: &World) -> Template { reflect_component.reflect(entity_ref.unwrap()) }); - template! { + bsn! { Node { flex_direction: FlexDirection::Column, - margin: UiRect::all(Val::Px(4.0)), - - ..Default::default() - } => [ - // Collapsible header for the component + margin: UiRect::bottom(Val::Px(6.0)), + border: UiRect::all(Val::Px(1.0)), + padding: UiRect::all(Val::Px(0.0)) + } + // CSS: #2A2A2E - Component background + BackgroundColor(Color::srgb(0.165, 0.165, 0.180)) + // CSS: #414142 - Border color + BorderColor::all(Color::srgb(0.255, 0.255, 0.259)) + BorderRadius::all(Val::Px(5.0)) + [ + // Component header - CSS styling Node { flex_direction: FlexDirection::Row, + justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, - ..Default::default() - } => [ - ( - Text(format!("⯆ {name}")), - TextFont::from_font_size(14.0), - TextColor(Color::WHITE), - ); - ]; + padding: UiRect::all(Val::Px(8.0)), + height: Val::Px(26.0) + } + // CSS: #36373B - Header background + BackgroundColor(Color::srgb(0.212, 0.216, 0.231)) + BorderRadius::top(Val::Px(5.0)) + [ + Node { + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + column_gap: Val::Px(5.0) + } [ + Text("▼") + TextFont::from_font_size(12.0) + // CSS: #C4C4C4 - Chevron color + TextColor(Color::srgb(0.769, 0.769, 0.769)), + + Text({format!("{name}")}) + TextFont::from_font_size(12.0) + // CSS: #DCDCDC - Component name + TextColor(Color::srgb(0.863, 0.863, 0.863)), + ], + + Text("⋯") + TextFont::from_font_size(12.0) + // CSS: #C4C4C4 - Menu dots + TextColor(Color::srgb(0.769, 0.769, 0.769)), + ], // Component fields - @{ match reflect { - Some(reflect) => component(type_info, reflect), - None => template! { + ({ match reflect { + Some(reflect) => component(type_info, reflect, theme).boxed_scene(), + None => bsn! { Node { flex_direction: FlexDirection::Row, - ..Default::default() - } => [ - ( - Text("".into()), - TextFont::from_font_size(10.0), - TextColor(Color::srgb(1.0, 0.0, 0.0)), - ); - ]; - }, - } }; - ]; + padding: UiRect::all(Val::Px(8.0)) + } [ + Text("") + TextFont::from_font_size(11.0) + TextColor(Color::srgb(0.514, 0.514, 0.522)), + ] + }.boxed_scene(), + }}), + ] } }) - .collect() + .collect::>() } -fn component(type_info: Option<&TypeInfo>, reflect: &dyn Reflect) -> Template { +fn component(type_info: Option<&TypeInfo>, reflect: &dyn Reflect, theme: &Theme) -> impl Scene { match type_info { - Some(TypeInfo::Struct(struct_info)) => reflected_struct(struct_info, reflect), - Some(TypeInfo::TupleStruct(tuple_struct_info)) => reflected_tuple_struct(tuple_struct_info), - Some(TypeInfo::Enum(enum_info)) => reflected_enum(enum_info), - _ => template! {}, + Some(TypeInfo::Struct(info)) => reflected_struct(info, reflect, theme).boxed_scene(), + Some(TypeInfo::TupleStruct(info)) => reflected_tuple_struct(info, theme).boxed_scene(), + Some(TypeInfo::Enum(info)) => reflected_enum(info, theme).boxed_scene(), + _ => bsn! {}.boxed_scene(), } } -fn reflected_struct(struct_info: &StructInfo, reflect: &dyn Reflect) -> Template { +fn reflected_struct(struct_info: &StructInfo, reflect: &dyn Reflect, _theme: &Theme) -> impl Scene { let fields = struct_info .iter() .enumerate() - .flat_map(|(i, field)| { - let value = reflect + .map(|(i, field)| { + let field_reflect = reflect .reflect_ref() .as_struct() - .map(|s| s.field_at(i)) + .ok() + .and_then(|s| s.field_at(i)); + + let field_name = field.name(); + + let value_string = field_reflect .map(|v| format!("{v:?}")) - .unwrap_or("".to_string()); + .unwrap_or_else(|| "".to_string()); - template! { + bsn! { Node { flex_direction: FlexDirection::Row, margin: UiRect::vertical(Val::Px(2.0)), - ..Default::default() - } => [ - ( - Text(field.name().to_string()), - TextFont::from_font_size(12.0), - TextColor(Color::srgb(0.8, 0.8, 0.8)), - ); - ( - // Value (use reflection to get value as string) - Text(value), - TextFont::from_font_size(10.0), - TextColor(Color::WHITE), - ); - ]; + padding: UiRect::all(Val::Px(5.0)), + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + min_height: Val::Px(22.0) + } + // CSS: #36373B - Field background + BackgroundColor(Color::srgb(0.212, 0.216, 0.231)) + BorderRadius::all(Val::Px(3.0)) + [ + Text(field_name) + TextFont::from_font_size(12.0) + // CSS: #DADADA - Field labels + TextColor(Color::srgb(0.855, 0.855, 0.855)), + + Text({value_string.clone()}) + TextFont::from_font_size(12.0) + // CSS: #C2C2C2 - Field values + TextColor(Color::srgb(0.761, 0.761, 0.761)), + ] } }) - .collect::