From 6d37222bd9a45ba5e8cb8805ab5112f620ac3751 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Sat, 25 Apr 2026 15:19:20 -0700 Subject: [PATCH 01/15] Scene Components --- .../bevy_camera_controller/src/free_camera.rs | 2 +- crates/bevy_ecs/Cargo.toml | 3 + crates/bevy_ecs/macros/Cargo.toml | 3 + crates/bevy_ecs/macros/src/component.rs | 52 +++ crates/bevy_ecs/macros/src/lib.rs | 2 + crates/bevy_ecs/macros/src/scene.rs | 72 ++++ crates/bevy_feathers/src/controls/button.rs | 106 +++-- crates/bevy_feathers/src/controls/checkbox.rs | 133 +++--- .../bevy_feathers/src/controls/color_plane.rs | 133 +++--- .../src/controls/color_slider.rs | 220 +++++----- .../src/controls/color_swatch.rs | 52 +-- .../src/controls/disclosure_toggle.rs | 54 +-- crates/bevy_feathers/src/controls/menu.rs | 307 +++++++------- .../src/controls/number_input.rs | 187 ++++----- crates/bevy_feathers/src/controls/radio.rs | 118 +++--- crates/bevy_feathers/src/controls/slider.rs | 150 +++---- .../bevy_feathers/src/controls/text_input.rs | 164 ++++---- .../src/controls/toggle_switch.rs | 88 ++-- .../src/controls/virtual_keyboard.rs | 118 +++--- crates/bevy_feathers/src/display/icon.rs | 9 +- crates/bevy_render/src/view/mod.rs | 3 +- crates/bevy_scene/Cargo.toml | 4 +- crates/bevy_scene/macros/Cargo.toml | 1 - crates/bevy_scene/macros/src/bsn/codegen.rs | 125 ++++-- crates/bevy_scene/macros/src/bsn/parse.rs | 44 +- crates/bevy_scene/macros/src/bsn/types.rs | 5 +- crates/bevy_scene/src/lib.rs | 132 ++++++ crates/bevy_scene/src/scene.rs | 93 ++++ crates/bevy_scene/src/scene_list.rs | 21 +- examples/large_scenes/bevy_city/src/main.rs | 108 ++--- .../large_scenes/bevy_city/src/settings.rs | 55 +-- examples/ui/widgets/feathers_counter.rs | 12 +- examples/ui/widgets/feathers_gallery.rs | 397 +++++++----------- examples/ui/widgets/virtual_keyboard.rs | 6 +- 34 files changed, 1726 insertions(+), 1253 deletions(-) create mode 100644 crates/bevy_ecs/macros/src/scene.rs diff --git a/crates/bevy_camera_controller/src/free_camera.rs b/crates/bevy_camera_controller/src/free_camera.rs index 3c04c81405237..dc8531994a58c 100644 --- a/crates/bevy_camera_controller/src/free_camera.rs +++ b/crates/bevy_camera_controller/src/free_camera.rs @@ -72,7 +72,7 @@ const RADIANS_PER_DOT: f32 = 1.0 / 180.0; /// which is added to the entity as a required component. /// /// To activate the controller, add the [`FreeCameraPlugin`] to your [`App`]. -#[derive(Component)] +#[derive(Component, Clone)] #[require(FreeCameraState)] pub struct FreeCamera { /// Multiplier for pitch and yaw rotation speed. diff --git a/crates/bevy_ecs/Cargo.toml b/crates/bevy_ecs/Cargo.toml index cc4d7dadc7140..07d5bdbe85a2c 100644 --- a/crates/bevy_ecs/Cargo.toml +++ b/crates/bevy_ecs/Cargo.toml @@ -25,6 +25,9 @@ serialize = ["dep:serde", "bevy_platform/serialize", "indexmap/serde"] ## Adds runtime reflection support using `bevy_reflect`. bevy_reflect = ["dep:bevy_reflect"] +## Enables support for deriving `bevy_scene` scenes for Components +derive_scene = ["bevy_ecs_macros/scene"] + ## Extends reflection support to functions. reflect_functions = ["bevy_reflect", "bevy_reflect/functions"] reflect_auto_register = ["bevy_reflect", "bevy_reflect/auto_register"] diff --git a/crates/bevy_ecs/macros/Cargo.toml b/crates/bevy_ecs/macros/Cargo.toml index baccfd84bb6bf..a18af3aeff24b 100644 --- a/crates/bevy_ecs/macros/Cargo.toml +++ b/crates/bevy_ecs/macros/Cargo.toml @@ -8,6 +8,9 @@ license = "MIT OR Apache-2.0" [lib] proc-macro = true +[features] +scene = [] + [dependencies] bevy_macro_utils = { path = "../../bevy_macro_utils", version = "0.19.0-dev" } diff --git a/crates/bevy_ecs/macros/src/component.rs b/crates/bevy_ecs/macros/src/component.rs index c37f48e65b519..7a04f88fb66d8 100644 --- a/crates/bevy_ecs/macros/src/component.rs +++ b/crates/bevy_ecs/macros/src/component.rs @@ -324,6 +324,37 @@ pub fn derive_component(input: TokenStream) -> TokenStream { quote! {::core::option::Option::None} }; + #[cfg(feature = "scene")] + let scene_constructor = { + if attrs.scene.is_some() || attrs.scene_props.is_some() { + use crate::scene::Scene; + + let (scene, scene_props) = match (attrs.scene, attrs.scene_props) { + (None, Some(props)) => (Scene::default_scene_function(), Some(props)), + (Some(scene), None) => (scene, None), + (Some(scene), Some(props)) => (scene, Some(props)), + (None, None) => unreachable!(), + }; + + let bevy_scene = + bevy_macro_utils::BevyManifest::shared(|manifest| manifest.get_path("bevy_scene")); + register_required.push(quote! { + required_components.register_required(|| #bevy_scene::SceneComponent::new::<#struct_name #type_generics>(false)); + }); + crate::scene::derive_scene_constructor( + &ast, + &bevy_ecs_path, + &bevy_scene, + scene, + scene_props, + ) + } else { + proc_macro2::TokenStream::new() + } + }; + #[cfg(not(feature = "scene"))] + let scene_constructor = proc_macro2::TokenStream::new(); + // This puts `register_required` before `register_recursive_requires` to ensure that the constructors of _all_ top // level components are initialized first, giving them precedence over recursively defined constructors for the same component type TokenStream::from(quote! { @@ -358,6 +389,8 @@ pub fn derive_component(input: TokenStream) -> TokenStream { #relationship #relationship_target + + #scene_constructor }) } @@ -593,6 +626,10 @@ struct Attrs { immutable: bool, clone_behavior: Option, map_entities: Option, + #[cfg(feature = "scene")] + scene: Option, + #[cfg(feature = "scene")] + scene_props: Option, } #[derive(Clone, Copy)] @@ -634,6 +671,10 @@ fn parse_component_attr(ast: &DeriveInput) -> Result { immutable: false, clone_behavior: None, map_entities: None, + #[cfg(feature = "scene")] + scene: None, + #[cfg(feature = "scene")] + scene_props: None, }; let mut require_paths = HashSet::new(); @@ -686,6 +727,17 @@ fn parse_component_attr(ast: &DeriveInput) -> Result { attrs.map_entities = Some(nested.input.parse::()?); Ok(()) } else { + #[cfg(feature = "scene")] + { + if nested.path.is_ident("scene") { + attrs.scene = Some(nested.input.parse::()?); + return Ok(()); + } else if nested.path.is_ident("scene_props") { + nested.input.parse::()?; + attrs.scene_props = Some(nested.input.parse::()?); + return Ok(()); + } + } Err(nested.error("Unsupported attribute")) } })?; diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index c788289c4cd3f..5b240277023b2 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -9,6 +9,8 @@ mod event; mod message; mod query_data; mod query_filter; +#[cfg(feature = "scene")] +mod scene; mod template; mod variant_defaults; mod world_query; diff --git a/crates/bevy_ecs/macros/src/scene.rs b/crates/bevy_ecs/macros/src/scene.rs new file mode 100644 index 0000000000000..dac6cbd97bb42 --- /dev/null +++ b/crates/bevy_ecs/macros/src/scene.rs @@ -0,0 +1,72 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + parse_str, DeriveInput, LitStr, Path, Token, +}; + +/// Parsed scene information +pub(crate) enum Scene { + Function(Path), + Asset(LitStr), +} + +impl Scene { + pub(crate) fn default_scene_function() -> Self { + // Self::scene will always parse correctly + Scene::Function(parse_str::("Self::scene").unwrap()) + } +} + +impl Parse for Scene { + fn parse(input: ParseStream) -> syn::Result { + Ok(if input.peek(Token![=]) { + input.parse::()?; + if input.peek(LitStr) { + Scene::Asset(input.parse::()?) + } else { + Scene::Function(input.parse::()?) + } + } else { + Scene::default_scene_function() + }) + } +} + +pub(crate) fn derive_scene_constructor( + ast: &DeriveInput, + bevy_ecs: &Path, + bevy_scene: &Path, + scene: Scene, + scene_props: Option, +) -> TokenStream { + let struct_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); + + let scene_impl = match scene { + Scene::Function(path) => { + if scene_props.is_some() { + quote! {#path(props)} + } else { + quote! {#path()} + } + } + Scene::Asset(lit_str) => quote! {#bevy_scene::InheritSceneAsset::from(#lit_str)}, + }; + let props_type = match scene_props { + Some(props) => quote! {#props}, + None => quote! {()}, + }; + quote! { + impl #impl_generics #bevy_scene::SceneConstructor for #struct_name #type_generics #where_clause { + type Props = #props_type; + fn scene(props: Self::Props) -> impl Scene { + ( + #scene_impl, + #bevy_scene::InitTemplate::<<#struct_name #type_generics as #bevy_ecs::template::FromTemplate>::Template>::default(), + #bevy_scene::template_value(#bevy_scene::SceneComponent::new::<#struct_name #type_generics>(true)), + ) + } + } + } +} diff --git a/crates/bevy_feathers/src/controls/button.rs b/crates/bevy_feathers/src/controls/button.rs index ae2bfe10d98c1..007e4c10038ee 100644 --- a/crates/bevy_feathers/src/controls/button.rs +++ b/crates/bevy_feathers/src/controls/button.rs @@ -44,8 +44,22 @@ pub enum ButtonVariant { Plain, } -/// Parameters for the button template, passed to [`button`] function. -pub struct ButtonProps { +/// A button widget. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersButtonProps`]. +/// +/// # Emitted events +/// * [`bevy_ui_widgets::Activate`] when any of the following happens: +/// * the pointer is released while hovering over the button. +/// * the ENTER or SPACE key is pressed while the button has keyboard focus. +/// +/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity +#[derive(Component, Default, Clone)] +#[component(scene_props = FeathersButtonProps)] +pub struct FeathersButton; + +/// Props used to construct a [`FeathersButton`] scene. +pub struct FeathersButtonProps { /// Label for this button. This can contain multiple entities, which will be contained /// in a horizontal flexbox. pub caption: Box, @@ -55,7 +69,7 @@ pub struct ButtonProps { pub corners: RoundedCorners, } -impl Default for ButtonProps { +impl Default for FeathersButtonProps { fn default() -> Self { Self { caption: Box::new(bsn_list!()), @@ -65,49 +79,39 @@ impl Default for ButtonProps { } } -/// Scene function to spawn a button. -/// -/// # Arguments -/// * `props` - construction properties for the button. -/// -/// # Emitted events -/// * [`bevy_ui_widgets::Activate`] when any of the following happens: -/// * the pointer is released while hovering over the button. -/// * the ENTER or SPACE key is pressed while the button has keyboard focus. -/// -/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -pub fn button(props: ButtonProps) -> impl Scene { - bsn! { - Node { - height: size::ROW_HEIGHT, - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)), - border_radius: {props.corners.to_border_radius(4.0)}, - } - Button - template_value(props.variant) - Hovered - EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) - FocusIndicator - ThemeBackgroundColor(tokens::BUTTON_BG) - InheritableThemeTextColor(tokens::BUTTON_TEXT) - InheritableFont { - font: fonts::REGULAR, - font_size: size::MEDIUM_FONT, - weight: FontWeight::NORMAL, +impl FeathersButton { + fn scene(props: FeathersButtonProps) -> impl Scene { + bsn! { + Node { + height: size::ROW_HEIGHT, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)), + border_radius: {props.corners.to_border_radius(4.0)}, + } + Button + template_value(props.variant) + Hovered + EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) + TabIndex(0) + FocusIndicator + ThemeBackgroundColor(tokens::BUTTON_BG) + InheritableThemeTextColor(tokens::BUTTON_TEXT) + InheritableFont { + font: fonts::REGULAR, + font_size: size::MEDIUM_FONT, + weight: FontWeight::NORMAL, + } + Children [ + {props.caption} + ] } - Children [ - {props.caption} - ] } } /// Tool button scene function: a smaller button for embedding in panel headers. /// -/// # Arguments -/// * `props` - construction properties for the button. +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersButtonProps`]. /// /// # Emitted events /// * [`bevy_ui_widgets::Activate`] when any of the following happens: @@ -115,12 +119,22 @@ pub fn button(props: ButtonProps) -> impl Scene { /// * the ENTER or SPACE key is pressed while the button has keyboard focus. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -pub fn tool_button(props: ButtonProps) -> impl Scene { - bsn! { - :button(props) - Node { - padding: UiRect::axes(Val::Px(4.0), Val::Px(0.)), - min_width: size::ROW_HEIGHT, +#[derive(Component, Default, Clone)] +#[component(scene_props = FeathersButtonProps)] +pub struct ToolButton; + +impl ToolButton { + fn scene(props: FeathersButtonProps) -> impl Scene { + bsn! { + :FeathersButton { + @caption: {props.caption}, + @variant: {props.variant}, + @corners: {props.corners} + } + Node { + padding: UiRect::axes(Val::Px(4.0), Val::Px(0.)), + min_width: size::ROW_HEIGHT, + } } } } diff --git a/crates/bevy_feathers/src/controls/checkbox.rs b/crates/bevy_feathers/src/controls/checkbox.rs index 55ebfb621fa77..bbcab091af9d2 100644 --- a/crates/bevy_feathers/src/controls/checkbox.rs +++ b/crates/bevy_feathers/src/controls/checkbox.rs @@ -12,6 +12,7 @@ use bevy_ecs::{ schedule::IntoScheduleConfigs, spawn::{Spawn, SpawnRelated, SpawnableList}, system::{Commands, Query}, + template::FromTemplate, }; use bevy_input_focus::tab_navigation::TabIndex; use bevy_math::Rot2; @@ -34,14 +35,26 @@ use crate::{ tokens, }; -/// Parameters for the checkbox template, passed to [`checkbox`] function. -pub struct CheckboxProps { +/// A checkbox widget. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersCheckboxProps`]. +/// +/// # Emitted events +/// * [`bevy_ui_widgets::ValueChange`] with the new value when the checkbox changes state. +/// +/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity +#[derive(Component, FromTemplate)] +#[component(scene_props = FeathersCheckboxProps)] +pub struct FeathersCheckbox; + +/// Props used to construct a [`FeathersCheckbox`] scene. +pub struct FeathersCheckboxProps { /// Label for this checkbox. This can contain multiple entities, which will be contained /// in a flexbox. pub caption: Box, } -impl Default for CheckboxProps { +impl Default for FeathersCheckboxProps { fn default() -> Self { Self { caption: Box::new(bsn_list!()), @@ -49,6 +62,61 @@ impl Default for CheckboxProps { } } +impl FeathersCheckbox { + fn scene(props: FeathersCheckboxProps) -> impl Scene { + bsn! { + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::Start, + align_items: AlignItems::Center, + column_gap: Val::Px(4.0), + } + Checkbox + CheckboxFrame + Hovered + EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) + TabIndex(0) + InheritableThemeTextColor(tokens::CHECKBOX_TEXT) + InheritableFont { + font: fonts::REGULAR, + font_size: size::MEDIUM_FONT, + weight: FontWeight::NORMAL, + } + Children [( + Node { + width: size::CHECKBOX_SIZE, + height: size::CHECKBOX_SIZE, + border: UiRect::all(Val::Px(2.0)), + border_radius: BorderRadius::all(Val::Px(4.0)), + } + CheckboxOutline + ThemeBackgroundColor(tokens::CHECKBOX_BG) + ThemeBorderColor(tokens::CHECKBOX_BORDER) + FocusIndicator + Children [( + // Cheesy checkmark: rotated node with L-shaped border. + Node { + position_type: PositionType::Absolute, + left: Val::Px(4.0), + top: Val::Px(0.0), + width: Val::Px(6.), + height: Val::Px(11.), + border: UiRect { + bottom: Val::Px(2.0), + right: Val::Px(2.0), + }, + } + UiTransform::from_rotation(Rot2::FRAC_PI_4) + CheckboxMark + ThemeBorderColor(tokens::CHECKBOX_MARK) + )]), + {props.caption} + ] + } + } +} + /// Marker for the checkbox frame (contains both checkbox and label) #[derive(Component, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] @@ -64,65 +132,6 @@ struct CheckboxOutline; #[reflect(Component, Clone, Default)] struct CheckboxMark; -/// Scene function to spawn a checkbox. -/// -/// # Emitted events -/// * [`bevy_ui_widgets::ValueChange`] with the new value when the checkbox changes state. -/// -/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -pub fn checkbox(props: CheckboxProps) -> impl Scene { - bsn! { - Node { - display: Display::Flex, - flex_direction: FlexDirection::Row, - justify_content: JustifyContent::Start, - align_items: AlignItems::Center, - column_gap: Val::Px(4.0), - } - Checkbox - CheckboxFrame - Hovered - EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) - InheritableThemeTextColor(tokens::CHECKBOX_TEXT) - InheritableFont { - font: fonts::REGULAR, - font_size: size::MEDIUM_FONT, - weight: FontWeight::NORMAL, - } - Children [( - Node { - width: size::CHECKBOX_SIZE, - height: size::CHECKBOX_SIZE, - border: UiRect::all(Val::Px(2.0)), - border_radius: BorderRadius::all(Val::Px(4.0)), - } - CheckboxOutline - ThemeBackgroundColor(tokens::CHECKBOX_BG) - ThemeBorderColor(tokens::CHECKBOX_BORDER) - FocusIndicator - Children [( - // Cheesy checkmark: rotated node with L-shaped border. - Node { - position_type: PositionType::Absolute, - left: Val::Px(4.0), - top: Val::Px(0.0), - width: Val::Px(6.), - height: Val::Px(11.), - border: UiRect { - bottom: Val::Px(2.0), - right: Val::Px(2.0), - }, - } - UiTransform::from_rotation(Rot2::FRAC_PI_4) - CheckboxMark - ThemeBorderColor(tokens::CHECKBOX_MARK) - )]), - {props.caption} - ] - } -} - /// Template function to spawn a checkbox. /// /// This version does not take any props. A caption can be set by appending a child entity. diff --git a/crates/bevy_feathers/src/controls/color_plane.rs b/crates/bevy_feathers/src/controls/color_plane.rs index 2d1dcdb24959e..2b12f91619e47 100644 --- a/crates/bevy_feathers/src/controls/color_plane.rs +++ b/crates/bevy_feathers/src/controls/color_plane.rs @@ -10,6 +10,7 @@ use bevy_ecs::{ query::{Changed, Has, Or, With}, reflect::ReflectComponent, system::{Commands, Query, Res, ResMut}, + template::FromTemplate, }; use bevy_math::{Vec2, Vec3}; use bevy_picking::{ @@ -18,7 +19,7 @@ use bevy_picking::{ }; use bevy_reflect::{prelude::ReflectDefault, Reflect, TypePath}; use bevy_render::render_resource::AsBindGroup; -use bevy_scene::{prelude::*, template_value}; +use bevy_scene::prelude::*; use bevy_shader::{ShaderDefVal, ShaderRef}; use bevy_ui::{ px, AlignSelf, BorderColor, BorderRadius, ComputedNode, ComputedUiRenderTargetInfo, Display, @@ -30,13 +31,23 @@ use bevy_ui_widgets::ValueChange; use crate::{cursor::EntityCursor, palette, theme::ThemeBackgroundColor, tokens}; -/// Marker identifying a color plane widget. +/// A "color plane" widget, which is a 2d picker that allows selecting two +/// components of a color space. /// -/// The variant selects which view of the color pane is shown. -#[derive(Component, Default, Debug, Clone, Reflect, Copy, PartialEq, Eq, Hash)] -#[reflect(Component, Clone, Default)] +/// This is spawnable by inheriting it as a "scene component". +/// +/// The control emits a [`ValueChange`] representing the current x and y values, ranging +/// from 0 to 1. The control accepts a [`Vec3`] input value, where the third component ('z') +/// is used to provide the fixed constant channel for the background gradient. +/// +/// The control does not do any color space conversions internally, other than the shader code +/// for displaying gradients. Avoiding excess conversions helps avoid gimble-lock problems when +/// implementing a color picker for cylindrical color spaces such as HSL. +#[derive(Component, FromTemplate, Debug, Reflect, Copy, PartialEq, Eq, Hash, Default, Clone)] +#[component(scene)] +#[reflect(Component)] #[require(ColorPlaneDragState)] -pub enum ColorPlane { +pub enum FeathersColorPlane { /// Show red on the horizontal axis and green on the vertical. RedGreen, /// Show red on the horizontal axis and blue on the vertical. @@ -75,13 +86,13 @@ struct ColorPlaneDragState(bool); #[repr(C)] #[derive(Eq, PartialEq, Hash, Copy, Clone)] struct ColorPlaneMaterialKey { - plane: ColorPlane, + plane: FeathersColorPlane, } #[derive(AsBindGroup, Asset, TypePath, Default, Debug, Clone)] #[bind_group_data(ColorPlaneMaterialKey)] struct ColorPlaneMaterial { - plane: ColorPlane, + plane: FeathersColorPlane, #[uniform(0)] fixed_channel: f32, @@ -109,67 +120,58 @@ impl UiMaterial for ColorPlaneMaterial { key: bevy_ui_render::prelude::UiMaterialKey, ) { let plane_def = match key.bind_group_data.plane { - ColorPlane::RedGreen => "PLANE_RG", - ColorPlane::RedBlue => "PLANE_RB", - ColorPlane::GreenBlue => "PLANE_GB", - ColorPlane::HueSaturation => "PLANE_HS", - ColorPlane::HueLightness => "PLANE_HL", + FeathersColorPlane::RedGreen => "PLANE_RG", + FeathersColorPlane::RedBlue => "PLANE_RB", + FeathersColorPlane::GreenBlue => "PLANE_GB", + FeathersColorPlane::HueSaturation => "PLANE_HS", + FeathersColorPlane::HueLightness => "PLANE_HL", }; descriptor.fragment.as_mut().unwrap().shader_defs = vec![ShaderDefVal::Bool(plane_def.into(), true)]; } } -/// Scene function to spawn a "color plane", which is a 2d picker that allows selecting two -/// components of a color space. -/// -/// The control emits a [`ValueChange`] representing the current x and y values, ranging -/// from 0 to 1. The control accepts a [`Vec3`] input value, where the third component ('z') -/// is used to provide the fixed constant channel for the background gradient. -/// -/// The control does not do any color space conversions internally, other than the shader code -/// for displaying gradients. Avoiding excess conversions helps avoid gimble-lock problems when -/// implementing a color picker for cylindrical color spaces such as HSL. -pub fn color_plane(plane: ColorPlane) -> impl Scene { - bsn! { - Node { - display: Display::Flex, - min_height: px(100.0), - align_self: AlignSelf::Stretch, - padding: UiRect::all(px(4)), - border_radius: BorderRadius::all(px(5)), - } - template_value(plane) - ColorPlaneValue - ThemeBackgroundColor(tokens::COLOR_PLANE_BG) - EntityCursor::System(bevy_window::SystemCursorIcon::Crosshair) - Children [( +impl FeathersColorPlane { + fn scene() -> impl Scene { + bsn! { Node { + display: Display::Flex, + min_height: px(100.0), align_self: AlignSelf::Stretch, - flex_grow: 1.0, + padding: UiRect::all(px(4)), + border_radius: BorderRadius::all(px(5)), } - ColorPlaneInner + ColorPlaneValue + ThemeBackgroundColor(tokens::COLOR_PLANE_BG) + EntityCursor::System(bevy_window::SystemCursorIcon::Crosshair) Children [( Node { - position_type: PositionType::Absolute, - left: Val::Percent(0.), - top: Val::Percent(0.), - width: px(10), - height: px(10), - border: UiRect::all(Val::Px(1.0)), - border_radius: BorderRadius::MAX, - } - ColorPlaneThumb - BorderColor::all(palette::WHITE) - Outline { - width: Val::Px(1.), - offset: Val::Px(0.), - color: palette::BLACK + align_self: AlignSelf::Stretch, + flex_grow: 1.0, } - Pickable::IGNORE - UiTransform::from_translation(Val2::new(Val::Percent(-50.0), Val::Percent(-50.0),)) + ColorPlaneInner + Children [( + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.), + top: Val::Percent(0.), + width: px(10), + height: px(10), + border: UiRect::all(Val::Px(1.0)), + border_radius: BorderRadius::MAX, + } + ColorPlaneThumb + BorderColor::all(palette::WHITE) + Outline { + width: Val::Px(1.), + offset: Val::Px(0.), + color: palette::BLACK + } + Pickable::IGNORE + UiTransform::from_translation(Val2::new(Val::Percent(-50.0), Val::Percent(-50.0),)) + )] )] - )] + } } } @@ -187,7 +189,7 @@ pub fn color_plane(plane: ColorPlane) -> impl Scene { /// # Arguments /// * `overrides` - a bundle of components that are merged in with the normal swatch components. #[deprecated(since = "0.19.0", note = "Use the color_plane() BSN function")] -pub fn color_plane_bundle(plane: ColorPlane, overrides: B) -> impl Bundle { +pub fn color_plane_bundle(plane: FeathersColorPlane, overrides: B) -> impl Bundle { ( Node { display: Display::Flex, @@ -236,8 +238,8 @@ pub fn color_plane_bundle(plane: ColorPlane, overrides: B) -> impl Bu fn update_plane_color( q_color_plane: Query< - (Entity, &ColorPlane, &ColorPlaneValue), - Or<(Changed, Changed)>, + (Entity, &FeathersColorPlane, &ColorPlaneValue), + Or<(Changed, Changed)>, >, q_children: Query<&Children>, q_material_node: Query<&MaterialNode>, @@ -291,7 +293,7 @@ fn update_plane_color( fn on_pointer_press( mut press: On>, - q_color_planes: Query, With>, + q_color_planes: Query, With>, q_color_plane_inner: Query< ( &ComputedNode, @@ -327,7 +329,7 @@ fn on_drag_start( mut drag_start: On>, mut q_color_planes: Query< (&mut ColorPlaneDragState, Has), - With, + With, >, q_color_plane_inner: Query<&ChildOf, With>, ) { @@ -343,7 +345,10 @@ fn on_drag_start( fn on_drag( mut drag: On>, - q_color_planes: Query<(&ColorPlaneDragState, Has), With>, + q_color_planes: Query< + (&ColorPlaneDragState, Has), + With, + >, q_color_plane_inner: Query< ( &ComputedNode, @@ -379,7 +384,7 @@ fn on_drag_end( mut drag_end: On>, mut q_color_planes: Query< (&mut ColorPlaneDragState, Has), - With, + With, >, q_color_plane_inner: Query< ( @@ -415,7 +420,7 @@ fn on_drag_end( fn on_drag_cancel( drag_cancel: On>, - mut q_color_planes: Query<&mut ColorPlaneDragState, With>, + mut q_color_planes: Query<&mut ColorPlaneDragState, With>, q_color_plane_inner: Query<&ChildOf, With>, ) { if let Ok(parent) = q_color_plane_inner.get(drag_cancel.entity) diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs index 64efdb304e3c5..0fcff5c1acc95 100644 --- a/crates/bevy_feathers/src/controls/color_slider.rs +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -143,15 +143,29 @@ impl ColorChannel { #[derive(Component, Default, Clone)] pub struct SliderBaseColor(pub Color); -/// Color slider template properties, passed to [`color_slider`] function. -pub struct ColorSliderProps { +/// A color slider widget. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersColorSliderProps`]. +/// +/// # Emitted events +/// +/// * [`bevy_ui_widgets::ValueChange`] when the slider value is changed. +/// +/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity +#[derive(Component, Default, Clone)] +#[component(scene_props = FeathersColorSliderProps)] +pub struct FeathersColorSlider; + +/// Props used to construct a [`FeathersColorSlider`] scene. +#[derive(Clone)] +pub struct FeathersColorSliderProps { /// Slider current value pub value: f32, /// Which color component we're editing pub channel: ColorChannel, } -impl Default for ColorSliderProps { +impl Default for FeathersColorSliderProps { fn default() -> Self { Self { value: 0.0, @@ -176,111 +190,102 @@ struct ColorSliderTrack; #[derive(Component, Default, Clone)] struct ColorSliderThumb; -/// Spawn a new slider scene. -/// -/// # Arguments -/// -/// * `props` - construction properties for the slider. -/// -/// # Emitted events -/// -/// * [`bevy_ui_widgets::ValueChange`] when the slider value is changed. -/// -/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -pub fn color_slider(props: ColorSliderProps) -> impl Scene { - bsn! { - Node { - display: Display::Flex, - flex_direction: FlexDirection::Row, - height: Val::Px(SLIDER_HEIGHT), - align_items: AlignItems::Stretch, - flex_grow: 1.0, - } - Slider { - track_click: TrackClick::Snap, - orientation: SliderOrientation::Horizontal, - } - ColorSlider { - channel: {props.channel}, - } - SliderValue({props.value}) - template_value(props.channel.range()) - EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) - FocusIndicator - Children [ - // track - ( - Node { - position_type: PositionType::Absolute, - left: Val::Px(0.), - right: Val::Px(0.), - top: Val::Px(TRACK_PADDING), - bottom: Val::Px(TRACK_PADDING), - border_radius: {RoundedCorners::All.to_border_radius(TRACK_RADIUS)}, - } - ColorSliderTrack - AlphaPattern - MaterialNode:: - Children [ - // Left endcap - ( - Node { - width: Val::Px({THUMB_SIZE * 0.5}), - border_radius: {RoundedCorners::Left.to_border_radius(TRACK_RADIUS)}, - } - BackgroundColor(palette::X_AXIS) - ), - // Track with gradient - ( - Node { - flex_grow: 1.0, - } - BackgroundGradient({vec![Gradient::Linear(LinearGradient { - angle: PI * 0.5, - stops: vec![ - ColorStop::new(Color::NONE, Val::Percent(0.)), - ColorStop::new(Color::NONE, Val::Percent(50.)), - ColorStop::new(Color::NONE, Val::Percent(100.)), - ], - color_space: InterpolationColorSpace::Srgba, - })]}) - ZIndex(1) - Children [( +impl FeathersColorSlider { + fn scene(props: FeathersColorSliderProps) -> impl Scene { + bsn! { + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + height: Val::Px(SLIDER_HEIGHT), + align_items: AlignItems::Stretch, + flex_grow: 1.0, + } + Slider { + track_click: TrackClick::Snap, + orientation: SliderOrientation::Horizontal, + } + ColorSlider { + channel: {props.channel}, + } + SliderValue({props.value}) + template_value(props.channel.range()) + EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) + TabIndex(0) + FocusIndicator + Children [ + // track + ( + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.), + right: Val::Px(0.), + top: Val::Px(TRACK_PADDING), + bottom: Val::Px(TRACK_PADDING), + border_radius: {RoundedCorners::All.to_border_radius(TRACK_RADIUS)}, + } + ColorSliderTrack + AlphaPattern + MaterialNode:: + Children [ + // Left endcap + ( Node { - position_type: PositionType::Absolute, - left: Val::Percent(0.), - top: Val::Percent(50.), - width: Val::Px(THUMB_SIZE), - height: Val::Px(THUMB_SIZE), - border: UiRect::all(Val::Px(2.0)), - border_radius: BorderRadius::MAX, + width: Val::Px({THUMB_SIZE * 0.5}), + border_radius: {RoundedCorners::Left.to_border_radius(TRACK_RADIUS)}, } - SliderThumb - ColorSliderThumb - BorderColor::all(palette::WHITE) - Outline { - width: Val::Px(1.), - offset: Val::Px(0.), - color: palette::BLACK + BackgroundColor(palette::X_AXIS) + ), + // Track with gradient + ( + Node { + flex_grow: 1.0, } - UiTransform::from_translation(Val2::new( - Val::Percent(-50.0), - Val::Percent(-50.0), - )) - )] - ), - // Right endcap - ( - Node { - width: Val::Px({THUMB_SIZE * 0.5}), - border_radius: {RoundedCorners::Right.to_border_radius(TRACK_RADIUS)}, - } - BackgroundColor(palette::Z_AXIS) - ) - ] - ) - ] + BackgroundGradient({vec![Gradient::Linear(LinearGradient { + angle: PI * 0.5, + stops: vec![ + ColorStop::new(Color::NONE, Val::Percent(0.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(100.)), + ], + color_space: InterpolationColorSpace::Srgba, + })]}) + ZIndex(1) + Children [( + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.), + top: Val::Percent(50.), + width: Val::Px(THUMB_SIZE), + height: Val::Px(THUMB_SIZE), + border: UiRect::all(Val::Px(2.0)), + border_radius: BorderRadius::MAX, + } + SliderThumb + ColorSliderThumb + BorderColor::all(palette::WHITE) + Outline { + width: Val::Px(1.), + offset: Val::Px(0.), + color: palette::BLACK + } + UiTransform::from_translation(Val2::new( + Val::Percent(-50.0), + Val::Percent(-50.0), + )) + )] + ), + // Right endcap + ( + Node { + width: Val::Px({THUMB_SIZE * 0.5}), + border_radius: {RoundedCorners::Right.to_border_radius(TRACK_RADIUS)}, + } + BackgroundColor(palette::Z_AXIS) + ) + ] + ) + ] + } } } @@ -297,7 +302,10 @@ pub fn color_slider(props: ColorSliderProps) -> impl Scene { /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity #[deprecated(since = "0.19.0", note = "Use the color_slider() BSN function")] -pub fn color_slider_bundle(props: ColorSliderProps, overrides: B) -> impl Bundle { +pub fn color_slider_bundle( + props: FeathersColorSliderProps, + overrides: B, +) -> impl Bundle { ( Node { display: Display::Flex, diff --git a/crates/bevy_feathers/src/controls/color_swatch.rs b/crates/bevy_feathers/src/controls/color_swatch.rs index 81ca40451baf8..d60982df23d4f 100644 --- a/crates/bevy_feathers/src/controls/color_swatch.rs +++ b/crates/bevy_feathers/src/controls/color_swatch.rs @@ -21,10 +21,13 @@ use crate::{ palette, }; -/// Marker identifying a color swatch. +/// A color swatch widget. +/// +/// This is spawnable by inheriting it as a "scene component". #[derive(Component, Default, Clone, Reflect)] +#[component(scene)] #[reflect(Component, Clone, Default)] -pub struct ColorSwatch; +pub struct FeathersColorSwatch; /// Component that contains the value of the color swatch. This is copied to the child element /// background. @@ -39,30 +42,31 @@ pub struct ColorSwatchValue(pub Color); #[reflect(Component, Clone, Default)] pub struct ColorSwatchFg; -/// Scene function to spawn a color swatch. -pub fn color_swatch() -> impl Scene { - bsn! { - Node { - height: size::ROW_HEIGHT, - min_width: size::ROW_HEIGHT, - border_radius: BorderRadius::all(Val::Px(5.0)), - } - ColorSwatch - ColorSwatchValue - AlphaPattern - MaterialNode:: - Children [( +impl FeathersColorSwatch { + fn scene() -> impl Scene { + bsn! { Node { - position_type: PositionType::Absolute, - left: Val::Px(0.), - top: Val::Px(0.), - bottom: Val::Px(0.), - right: Val::Px(0.), + height: size::ROW_HEIGHT, + min_width: size::ROW_HEIGHT, border_radius: BorderRadius::all(Val::Px(5.0)), } - ColorSwatchFg - BackgroundColor({palette::ACCENT.with_alpha(0.5)}) - )] + FeathersColorSwatch + ColorSwatchValue + AlphaPattern + MaterialNode:: + Children [( + Node { + position_type: PositionType::Absolute, + left: Val::Px(0.), + top: Val::Px(0.), + bottom: Val::Px(0.), + right: Val::Px(0.), + border_radius: BorderRadius::all(Val::Px(5.0)), + } + ColorSwatchFg + BackgroundColor({palette::ACCENT.with_alpha(0.5)}) + )] + } } } @@ -79,7 +83,7 @@ pub fn color_swatch_bundle(overrides: B) -> impl Bundle { border_radius: BorderRadius::all(Val::Px(5.0)), ..Default::default() }, - ColorSwatch, + FeathersColorSwatch, ColorSwatchValue::default(), AlphaPattern, MaterialNode::(Handle::default()), diff --git a/crates/bevy_feathers/src/controls/disclosure_toggle.rs b/crates/bevy_feathers/src/controls/disclosure_toggle.rs index 60782e470c897..416b8ca9016f8 100644 --- a/crates/bevy_feathers/src/controls/disclosure_toggle.rs +++ b/crates/bevy_feathers/src/controls/disclosure_toggle.rs @@ -4,14 +4,12 @@ use bevy_ecs::{ hierarchy::Children, lifecycle::RemovedComponents, query::{Added, Has, Or, With}, - reflect::ReflectComponent, schedule::IntoScheduleConfigs, system::{Query, Res}, }; use bevy_input_focus::tab_navigation::TabIndex; use bevy_math::Rot2; use bevy_picking::PickingSystems; -use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_scene::{bsn, Scene}; use bevy_ui::{ px, widget::ImageNode, AlignItems, Checked, Display, InteractionDisabled, JustifyContent, Node, @@ -25,32 +23,34 @@ use crate::{ tokens, }; -/// Marker for the disclosure toggle widget -#[derive(Component, Default, Clone, Reflect)] -#[reflect(Component, Clone, Default)] -struct DisclosureToggleStyle; - /// A toggle button which shows a chevron that points either right or down, used to expand or /// collapse a panel. Functionally, this is equivalent to a checkbox, and has a [`Checked`] /// state. -pub fn disclosure_toggle() -> impl Scene { - bsn!( - Node { - width: px(12), - height: px(12), - display: Display::Flex, - align_items: AlignItems::Center, - justify_content: JustifyContent::Center, - } - Checkbox - DisclosureToggleStyle - EntityCursor::System(SystemCursorIcon::Pointer) - FocusIndicator - TabIndex(0) - Children [ - :icon(icons::CHEVRON_RIGHT) - ] - ) +/// +/// This is spawnable by inheriting it as a "scene component". +#[derive(Component, Default, Clone)] +#[component(scene)] +pub struct FeathersDisclosureToggle; + +impl FeathersDisclosureToggle { + fn scene() -> impl Scene { + bsn!( + Node { + width: px(12), + height: px(12), + display: Display::Flex, + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + } + Checkbox + EntityCursor::System(SystemCursorIcon::Pointer) + FocusIndicator + TabIndex(0) + Children [ + :icon(icons::CHEVRON_RIGHT) + ] + ) + } } fn update_toggle_styles( @@ -62,7 +62,7 @@ fn update_toggle_styles( &Children, ), ( - With, + With, Or<(Added, Added, Added)>, ), >, @@ -94,7 +94,7 @@ fn update_toggle_styles_remove( &mut UiTransform, &Children, ), - With, + With, >, mut q_icon: Query<&mut ImageNode>, mut removed_disabled: RemovedComponents, diff --git a/crates/bevy_feathers/src/controls/menu.rs b/crates/bevy_feathers/src/controls/menu.rs index 931939783975a..5796049401435 100644 --- a/crates/bevy_feathers/src/controls/menu.rs +++ b/crates/bevy_feathers/src/controls/menu.rs @@ -27,7 +27,7 @@ use bevy_ui_widgets::{ use crate::{ constants::{fonts, icons, size}, - controls::{button, ButtonProps, ButtonVariant}, + controls::{ButtonVariant, FeathersButton}, cursor::EntityCursor, display::icon, font_styles::InheritableFont, @@ -40,52 +40,24 @@ use bevy_input_focus::{ FocusCause, InputFocus, InputFocusVisible, }; -/// Parameters for the menu button template, passed to [`menu_button`] function. -pub struct MenuButtonProps { - /// Label for this menu button - pub caption: Box, - /// Rounded corners options - pub corners: RoundedCorners, - /// Include the standard downward-pointing chevron (default true). - pub arrow: bool, -} - -impl Default for MenuButtonProps { - fn default() -> Self { - Self { - caption: Box::new(bsn_list!()), - corners: Default::default(), - arrow: true, - } - } -} - -/// Marker for menu button -#[derive(Component, Default, Clone)] -struct FeathersMenuButton; - -/// Marker for menu items -#[derive(Component, Default, Clone)] -struct FeathersMenuItem; - -/// Marker for menu popup -#[derive(Component, Default, Clone)] -struct FeathersMenuPopup; - -/// Component that contains the popup content generator. +/// Top-level menu container. This wraps the menu button and provides an anchor for the popover. +/// +/// This is spawnable by inheriting it as a "scene component". #[derive(Component, Clone, Default)] -struct FeathersMenuContainer; +#[component(scene)] +pub struct FeathersMenu; -/// Menu scene function. This wraps the menu button and provides an anchor for the popover. -pub fn menu() -> impl Scene { - bsn! { - Node { - height: size::ROW_HEIGHT, - justify_content: JustifyContent::Stretch, - align_items: AlignItems::Stretch, +impl FeathersMenu { + fn scene() -> impl Scene { + bsn! { + Node { + height: size::ROW_HEIGHT, + justify_content: JustifyContent::Stretch, + align_items: AlignItems::Stretch, + } + FeathersMenu + on(on_menu_event) } - FeathersMenuContainer - on(on_menu_event) } } @@ -159,85 +131,119 @@ fn on_menu_event( } } -/// Menu button scene function. This produces a button that has a dropdown arrow. -/// -/// # Arguments -/// * `props` - construction properties for the button. -pub fn menu_button(props: MenuButtonProps) -> impl Scene { - bsn! { - :button(ButtonProps { - caption: props.caption, - variant: ButtonVariant::Normal, - corners: props.corners, - }) - ActivateOnPress - MenuButton - FeathersMenuButton - // Additional children for menu chevron - Children [ - { - if props.arrow { - Box::new(bsn_list!( - Node { - flex_grow: 1.0, - }, - :icon(icons::CHEVRON_DOWN), - )) as Box - } else { - Box::new(bsn_list!()) as Box - } +/// A menu button widget. This produces a button that has a dropdown arrow. +/// This can be spawned as a "scene component" with [`MenuButtonProps`]. +#[derive(Component, Default, Clone)] +#[component(scene_props = MenuButtonProps)] +pub struct FeathersMenuButton; + +/// Parameters for the menu button template, passed to [`menu_button`] function. +pub struct MenuButtonProps { + /// Label for this menu button + pub caption: Box, + /// Rounded corners options + pub corners: RoundedCorners, + /// Include the standard downward-pointing chevron (default true). + pub arrow: bool, +} + +impl Default for MenuButtonProps { + fn default() -> Self { + Self { + caption: Box::new(bsn_list!()), + corners: Default::default(), + arrow: true, + } + } +} +impl FeathersMenuButton { + fn scene(props: MenuButtonProps) -> impl Scene { + bsn! { + :FeathersButton { + @caption: {props.caption}, + @variant: ButtonVariant::Normal, + @corners: {props.corners}, } - ] + ActivateOnPress + MenuButton + FeathersMenuButton + // Additional children for menu chevron + Children [ + { + if props.arrow { + Box::new(bsn_list!( + Node { + flex_grow: 1.0, + }, + :icon(icons::CHEVRON_DOWN), + )) as Box + } else { + Box::new(bsn_list!()) as Box + } + } + ] + } } } -/// Menu Popup scene function -pub fn menu_popup() -> impl Scene { - bsn! { - Node { - position_type: PositionType::Absolute, - display: Display::Flex, - flex_direction: FlexDirection::Column, - justify_content: JustifyContent::Stretch, - align_items: AlignItems::Stretch, - border: UiRect::all(Val::Px(1.0)), - padding: UiRect::axes(Val::Px(0.0), Val::Px(4.0)), - border_radius: {RoundedCorners::All.to_border_radius(4.0)}, - } - FeathersMenuPopup - MenuPopup - template_value(Visibility::Hidden) - ThemeBackgroundColor(tokens::MENU_BG) - ThemeBorderColor(tokens::MENU_BORDER) - BoxShadow::new( - Srgba::BLACK.with_alpha(0.9).into(), - Val::Px(0.0), - Val::Px(0.0), - Val::Px(1.0), - Val::Px(4.0), - ) - GlobalZIndex(100) - template_value( - Popover { - positions: vec![ - PopoverPlacement { - side: PopoverSide::Bottom, - align: PopoverAlign::Start, - gap: 2.0, - }, - PopoverPlacement { - side: PopoverSide::Top, - align: PopoverAlign::Start, - gap: 2.0, - }, - ], - window_margin: 10.0, +/// A menu popup widget. +#[derive(Component, Default, Clone)] +#[component(scene)] +pub struct FeathersMenuPopup; + +impl FeathersMenuPopup { + fn scene() -> impl Scene { + bsn! { + Node { + position_type: PositionType::Absolute, + display: Display::Flex, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Stretch, + align_items: AlignItems::Stretch, + border: UiRect::all(Val::Px(1.0)), + padding: UiRect::axes(Val::Px(0.0), Val::Px(4.0)), + border_radius: {RoundedCorners::All.to_border_radius(4.0)}, } - ) - OverrideClip + FeathersMenuPopup + MenuPopup + template_value(Visibility::Hidden) + ThemeBackgroundColor(tokens::MENU_BG) + ThemeBorderColor(tokens::MENU_BORDER) + BoxShadow::new( + Srgba::BLACK.with_alpha(0.9).into(), + Val::Px(0.0), + Val::Px(0.0), + Val::Px(1.0), + Val::Px(4.0), + ) + GlobalZIndex(100) + template_value( + Popover { + positions: vec![ + PopoverPlacement { + side: PopoverSide::Bottom, + align: PopoverAlign::Start, + gap: 2.0, + }, + PopoverPlacement { + side: PopoverSide::Top, + align: PopoverAlign::Start, + gap: 2.0, + }, + ], + window_margin: 10.0, + } + ) + OverrideClip + } } } +/// A menu item widget. +#[derive(Component, Default, Clone)] +#[component(scene_props = MenuItemProps )] +pub struct FeathersMenuItem; + /// Parameters for the menu button template, passed to [`menu_button`] function. pub struct MenuItemProps { /// Label for this menu item @@ -252,31 +258,32 @@ impl Default for MenuItemProps { } } -/// Menu item scene function -pub fn menu_item(props: MenuItemProps) -> impl Scene { - bsn! { - Node { - height: size::ROW_HEIGHT, - min_width: size::ROW_HEIGHT, - justify_content: JustifyContent::Start, - align_items: AlignItems::Center, - padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)), - } - FeathersMenuItem - MenuItem - Hovered - EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) - ThemeBackgroundColor(tokens::MENU_BG) // Same as menu - InheritableThemeTextColor(tokens::MENUITEM_TEXT) - InheritableFont { - font: fonts::REGULAR, - font_size: size::MEDIUM_FONT, - weight: FontWeight::NORMAL, +impl FeathersMenuItem { + fn scene(props: MenuItemProps) -> impl Scene { + bsn! { + Node { + height: size::ROW_HEIGHT, + min_width: size::ROW_HEIGHT, + justify_content: JustifyContent::Start, + align_items: AlignItems::Center, + padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)), + } + FeathersMenuItem + MenuItem + Hovered + EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) + TabIndex(0) + ThemeBackgroundColor(tokens::MENU_BG) // Same as menu + InheritableThemeTextColor(tokens::MENUITEM_TEXT) + InheritableFont { + font: fonts::REGULAR, + font_size: size::MEDIUM_FONT, + weight: FontWeight::NORMAL, + } + Children [ + {props.caption} + ] } - Children [ - {props.caption} - ] } } @@ -422,15 +429,21 @@ fn set_menuitem_colors( } /// A decorative divider between menu items -pub fn menu_divider() -> impl Scene { - bsn! { - Node { - height: px(1), - justify_content: JustifyContent::Start, - align_self: AlignSelf::Stretch, - margin: UiRect::axes(Val::Px(0.0), Val::Px(2.)), +#[derive(Component, Default, Clone)] +#[component(scene)] +pub struct FeathersMenuDivider; + +impl FeathersMenuDivider { + fn scene() -> impl Scene { + bsn! { + Node { + height: px(1), + justify_content: JustifyContent::Start, + align_self: AlignSelf::Stretch, + margin: UiRect::axes(Val::Px(0.0), Val::Px(2.)), + } + ThemeBackgroundColor(tokens::MENU_BORDER) // Same as menu } - ThemeBackgroundColor(tokens::MENU_BORDER) // Same as menu } } diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 1a28935f81d7d..276c6c44c7ccb 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -1,5 +1,4 @@ use bevy_app::PropagateOver; -use bevy_asset::AssetServer; use bevy_ecs::{ component::Component, entity::Entity, @@ -9,46 +8,52 @@ use bevy_ecs::{ query::With, relationship::Relationship, system::{Commands, Query, Res}, - template::template, }; use bevy_input::keyboard::{KeyCode, KeyboardInput}; use bevy_input_focus::{FocusLost, FocusedInput, InputFocus}; use bevy_log::warn; use bevy_scene::prelude::*; use bevy_text::{ - EditableText, EditableTextFilter, FontSource, FontWeight, TextEdit, TextEditChange, TextFont, + EditableText, EditableTextFilter, FontSourceTemplate, FontWeight, TextEdit, TextEditChange, + TextFont, }; use bevy_ui::{px, widget::Text, AlignItems, AlignSelf, Display, JustifyContent, Node, UiRect}; use bevy_ui_widgets::{SelectAllOnFocus, ValueChange}; use crate::{ constants::{fonts, size}, - controls::{text_input, text_input_container, TextInputProps}, + controls::{FeathersTextInput, FeathersTextInputContainer}, theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeTextColor, ThemeToken}, tokens, }; -/// Marker to indicate a number input widget with feathers styling. +/// Widget that permits text entry of floating-point numbers. This widget implements two-way +/// synchronization: +/// * when the widget has focus, it emits values (via a [`ValueChange`]) event as the user types. +/// The type of ``T`` will be ``f32``, ``f64``, ``i32``, or ``i64`` depending on the +/// ``number_format`` parameter. +/// * when the widget does not have focus, it listens for [`UpdateNumberInput`] events, and replaces +/// the contents of the text buffer based on the value in that event. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersNumberInputProps`]. +/// +/// To avoid excessive updating, you should only update the number value when there is an actual +/// change, that is, when the new value is different from the current value. +/// +/// In most cases, the actual source of truth for the numeric value will be external, that is, +/// some property in an app-specific data structure. It's the responsibility of the app to +/// synchronize this value with the [`number_input`] widget in both directions: +/// * When a [`ValueChange`] event is received, update the app-specific property. +/// * When the app-specific property changes - either in response to a [`ValueChange`] event, or +/// because of some other action, trigger an [`UpdateNumberInput`] entity event to update the +/// displayed value. +// TODO: Add text_input field validation when it becomes available. #[derive(Component, Default, Clone)] -struct FeathersNumberInput; - -/// Used to indicate what format of numbers we are editing. This primarily affects the type -/// of [`ValueChange`] event that is emitted. -#[derive(Component, Default, Clone, Copy)] -pub enum NumberFormat { - /// A 32-bit float - #[default] - F32, - /// A 64-bit float - F64, - /// A 32-bit integer - I32, - /// A 64-bit integer - I64, -} +#[component(scene_props = FeathersNumberInputProps)] +pub struct FeathersNumberInput; -/// Parameters for the text input template, passed to [`number_input`] function. -pub struct NumberInputProps { +/// Props used to construct a [`FeathersNumberInput`] scene. +pub struct FeathersNumberInputProps { /// The "sigil" is a colored strip along the left edge of the input, which is used to /// distinguish between different axes. The default is transparent (no sigil). pub sigil_color: ThemeToken, @@ -59,7 +64,7 @@ pub struct NumberInputProps { pub number_format: NumberFormat, } -impl Default for NumberInputProps { +impl Default for FeathersNumberInputProps { fn default() -> Self { Self { sigil_color: tokens::TEXT_INPUT_BG, @@ -69,6 +74,70 @@ impl Default for NumberInputProps { } } +impl FeathersNumberInput { + fn scene(props: FeathersNumberInputProps) -> impl Scene { + bsn! { + :FeathersTextInputContainer + ThemeBorderColor({props.sigil_color}) + FeathersNumberInput + template_value(props.number_format) + on(number_input_on_update) + Children [ + { + match props.label_text { + Some(text) => Box::new(bsn_list!( + Node { + display: Display::Flex, + align_items: AlignItems::Center, + align_self: AlignSelf::Stretch, + justify_content: JustifyContent::Center, + padding: UiRect::axes(px(6), px(0)), + } + ThemeBackgroundColor(tokens::TEXT_INPUT_LABEL_BG) + Children [ + Text(text) + TextFont { + font: FontSourceTemplate::Handle(fonts::REGULAR), + font_size: size::COMPACT_FONT, + weight: FontWeight::NORMAL, + } + PropagateOver + ThemeTextColor(tokens::TEXT_INPUT_TEXT) + ] + )) as Box, + None => Box::new(bsn_list!()) as Box + } + } + :FeathersTextInput { + @max_characters: 20usize, + } + SelectAllOnFocus + on(number_input_on_text_change) + on(number_input_on_enter_key) + on(number_input_on_focus_loss) + EditableTextFilter::new(|c| { + c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E') + }), + ] + } + } +} + +/// Used to indicate what format of numbers we are editing. This primarily affects the type +/// of [`ValueChange`] event that is emitted. +#[derive(Component, Default, Clone, Copy)] +pub enum NumberFormat { + /// A 32-bit float + #[default] + F32, + /// A 64-bit float + F64, + /// A 32-bit integer + I32, + /// A 64-bit integer + I64, +} + /// Represents numbers in different formats. #[derive(Debug, PartialEq, Clone, Copy)] pub enum NumberInputValue { @@ -103,76 +172,6 @@ pub struct UpdateNumberInput { pub value: NumberInputValue, } -/// Widget that permits text entry of floating-point numbers. This widget implements two-way -/// synchronization: -/// * when the widget has focus, it emits values (via a [`ValueChange`]) event as the user types. -/// The type of ``T`` will be ``f32``, ``f64``, ``i32``, or ``i64`` depending on the -/// ``number_format`` parameter. -/// * when the widget does not have focus, it listens for [`UpdateNumberInput`] events, and replaces -/// the contents of the text buffer based on the value in that event. -/// -/// To avoid excessive updating, you should only update the number value when there is an actual -/// change, that is, when the new value is different from the current value. -/// -/// In most cases, the actual source of truth for the numeric value will be external, that is, -/// some property in an app-specific data structure. It's the responsibility of the app to -/// synchronize this value with the [`number_input`] widget in both directions: -/// * When a [`ValueChange`] event is received, update the app-specific property. -/// * When the app-specific property changes - either in response to a [`ValueChange`] event, or -/// because of some other action, trigger an [`UpdateNumberInput`] entity event to update the -/// displayed value. -// TODO: Add text_input field validation when it becomes available. -pub fn number_input(props: NumberInputProps) -> impl Scene { - bsn! { - :text_input_container() - ThemeBorderColor({props.sigil_color.clone()}) - FeathersNumberInput - template_value(props.number_format) - on(number_input_on_update) - Children [ - { - match props.label_text { - Some(text) => Box::new(bsn_list!( - Node { - display: Display::Flex, - align_items: AlignItems::Center, - align_self: AlignSelf::Stretch, - justify_content: JustifyContent::Center, - padding: UiRect::axes(px(6), px(0)), - } - ThemeBackgroundColor(tokens::TEXT_INPUT_LABEL_BG) - Children [ - Text::new(text.to_string()) - template(|ctx| { - Ok(TextFont { - font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), - font_size: size::COMPACT_FONT, - weight: FontWeight::NORMAL, - ..Default::default() - }) - }) - PropagateOver - ThemeTextColor(tokens::TEXT_INPUT_TEXT) - ] - )) as Box, - None => Box::new(bsn_list!()) as Box - } - } - text_input(TextInputProps { - visible_width: None, - max_characters: Some(20), - }) - SelectAllOnFocus, - on(number_input_on_text_change) - on(number_input_on_enter_key) - on(number_input_on_focus_loss) - EditableTextFilter::new(|c| { - c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E') - }), - ] - } -} - fn number_input_on_text_change( change: On, q_parent: Query<&ChildOf>, diff --git a/crates/bevy_feathers/src/controls/radio.rs b/crates/bevy_feathers/src/controls/radio.rs index c24a29f9de611..48e6f6bd437c2 100644 --- a/crates/bevy_feathers/src/controls/radio.rs +++ b/crates/bevy_feathers/src/controls/radio.rs @@ -33,24 +33,27 @@ use crate::{ tokens, }; -/// Marker for the radio outline -#[derive(Component, Default, Clone, Reflect)] -#[reflect(Component, Clone, Default)] -struct RadioOutline; - -/// Marker for the radio check mark -#[derive(Component, Default, Clone, Reflect)] -#[reflect(Component, Clone, Default)] -struct RadioMark; +/// A radio widget. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersRadioProps`]. +/// +/// # Emitted events +/// * [`bevy_ui_widgets::ValueChange`] with the value true when it becomes checked. +/// * [`bevy_ui_widgets::ValueChange`] with the selected entity's id when a new radio button is selected. +/// +/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity +#[derive(Component, Default, Clone)] +#[component(scene_props = FeathersRadioProps)] +pub struct FeathersRadio; -/// Parameters for the radio button template, passed to [`radio`] function. -pub struct RadioProps { +/// Props used to construct a [`FeathersRadio`] scene. +pub struct FeathersRadioProps { /// Label for this radio button. This can contain multiple entities, which will be contained /// in a flexbox. pub caption: Box, } -impl Default for RadioProps { +impl Default for FeathersRadioProps { fn default() -> Self { Self { caption: Box::new(bsn_list!()), @@ -58,60 +61,65 @@ impl Default for RadioProps { } } -/// Scene function to spawn a radio. -/// -/// # Emitted events -/// * [`bevy_ui_widgets::ValueChange`] with the value true when it becomes checked. -/// * [`bevy_ui_widgets::ValueChange`] with the selected entity's id when a new radio button is selected. -/// -/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -pub fn radio(props: RadioProps) -> impl Scene { - bsn! { - Node { - display: Display::Flex, - flex_direction: FlexDirection::Row, - justify_content: JustifyContent::Start, - align_items: AlignItems::Center, - column_gap: Val::Px(4.0), - } - RadioButton - Hovered - EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) - InheritableThemeTextColor(tokens::RADIO_TEXT) - InheritableFont { - font: fonts::REGULAR, - font_size: size::MEDIUM_FONT, - weight: FontWeight::NORMAL, - } - Children [( +impl FeathersRadio { + fn scene(props: FeathersRadioProps) -> impl Scene { + bsn! { Node { display: Display::Flex, + flex_direction: FlexDirection::Row, + justify_content: JustifyContent::Start, align_items: AlignItems::Center, - justify_content: JustifyContent::Center, - width: size::RADIO_SIZE, - height: size::RADIO_SIZE, - border: UiRect::all(Val::Px(2.0)), - border_radius: BorderRadius::MAX, + column_gap: Val::Px(4.0), + } + RadioButton + Hovered + EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) + TabIndex(0) + InheritableThemeTextColor(tokens::RADIO_TEXT) + InheritableFont { + font: fonts::REGULAR, + font_size: size::MEDIUM_FONT, + weight: FontWeight::NORMAL, } - RadioOutline - FocusIndicator - ThemeBorderColor(tokens::RADIO_BORDER) Children [( - // Cheesy checkmark: rotated node with L-shaped border. Node { - width: Val::Px(8.), - height: Val::Px(8.), + display: Display::Flex, + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + width: size::RADIO_SIZE, + height: size::RADIO_SIZE, + border: UiRect::all(Val::Px(2.0)), border_radius: BorderRadius::MAX, } - RadioMark - ThemeBackgroundColor(tokens::RADIO_MARK) - )]), - {props.caption} - ] + RadioOutline + FocusIndicator + ThemeBorderColor(tokens::RADIO_BORDER) + Children [( + // Cheesy checkmark: rotated node with L-shaped border. + Node { + width: Val::Px(8.), + height: Val::Px(8.), + border_radius: BorderRadius::MAX, + } + RadioMark + ThemeBackgroundColor(tokens::RADIO_MARK) + )]), + {props.caption} + ] + } } } +/// Marker for the radio outline +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +struct RadioOutline; + +/// Marker for the radio check mark +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +struct RadioMark; + /// Template function to spawn a radio. /// /// This version does not take any props. A caption can be set by appending a child entity. diff --git a/crates/bevy_feathers/src/controls/slider.rs b/crates/bevy_feathers/src/controls/slider.rs index c1cc0c38d4066..ecb34d5b31e9a 100644 --- a/crates/bevy_feathers/src/controls/slider.rs +++ b/crates/bevy_feathers/src/controls/slider.rs @@ -38,8 +38,23 @@ use crate::{ tokens, }; -/// Slider template properties, passed to [`slider`] function. -pub struct SliderProps { +/// A slider widget. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersSliderProps`]. +/// +/// # Emitted events +/// +/// * [`bevy_ui_widgets::ValueChange`] when the slider value is changed. +/// +/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity +#[derive(Component, Default, Clone, Reflect)] +#[component(scene_props = FeathersSliderProps)] +#[require(Slider)] +#[reflect(Component, Clone, Default)] +pub struct FeathersSlider; + +/// Props used to construct the [`FeathersSlider`] scene. +pub struct FeathersSliderProps { /// Slider current value pub value: f32, /// Slider minimum value @@ -48,7 +63,7 @@ pub struct SliderProps { pub max: f32, } -impl Default for SliderProps { +impl Default for FeathersSliderProps { fn default() -> Self { Self { value: 0.0, @@ -58,80 +73,65 @@ impl Default for SliderProps { } } -#[derive(Component, Default, Clone)] -#[require(Slider)] -#[derive(Reflect)] -#[reflect(Component, Clone, Default)] -struct SliderStyle; - -/// Marker for the text -#[derive(Component, Default, Clone, Reflect)] -#[reflect(Component, Clone, Default)] -struct SliderValueText; - -/// Spawn a new slider widget. -/// -/// # Arguments -/// -/// * `props` - construction properties for the slider. -/// -/// # Emitted events -/// -/// * [`bevy_ui_widgets::ValueChange`] when the slider value is changed. -/// -/// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -pub fn slider(props: SliderProps) -> impl Scene { - bsn! { - Node { - height: size::ROW_HEIGHT, - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)), - flex_grow: 1.0, - border_radius: {RoundedCorners::All.to_border_radius(6.0)}, - } - Hovered - Slider { - track_click: TrackClick::Drag, - orientation: SliderOrientation::Horizontal, - } - SliderStyle - SliderValue({props.value}) - SliderRange::new(props.min, props.max) - EntityCursor::System(bevy_window::SystemCursorIcon::EwResize) - TabIndex(0) - FocusIndicator - // Use a gradient to draw the moving bar - BackgroundGradient({vec![Gradient::Linear(LinearGradient { - angle: PI * 0.5, - stops: vec![ - ColorStop::new(Color::NONE, Val::Percent(0.)), - ColorStop::new(Color::NONE, Val::Percent(50.)), - ColorStop::new(Color::NONE, Val::Percent(50.)), - ColorStop::new(Color::NONE, Val::Percent(100.)), - ], - color_space: InterpolationColorSpace::Srgba, - })]}) - Children [( - // Text container +impl FeathersSlider { + fn scene(props: FeathersSliderProps) -> impl Scene { + bsn! { Node { - display: Display::Flex, - position_type: PositionType::Absolute, - flex_direction: FlexDirection::Row, - align_items: AlignItems::Center, + height: size::ROW_HEIGHT, justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)), + flex_grow: 1.0, + border_radius: {RoundedCorners::All.to_border_radius(6.0)}, } - InheritableThemeTextColor(tokens::SLIDER_TEXT) - InheritableFont { - font: fonts::MONO, - font_size: size::SMALL_FONT, - weight: FontWeight::NORMAL, + Hovered + Slider { + track_click: TrackClick::Drag, + orientation: SliderOrientation::Horizontal, } - Children [(Text("10.0") ThemedText SliderValueText)] - )] + FeathersSlider + SliderValue({props.value}) + SliderRange::new(props.min, props.max) + EntityCursor::System(bevy_window::SystemCursorIcon::EwResize) + TabIndex(0) + FocusIndicator + // Use a gradient to draw the moving bar + BackgroundGradient({vec![Gradient::Linear(LinearGradient { + angle: PI * 0.5, + stops: vec![ + ColorStop::new(Color::NONE, Val::Percent(0.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(50.)), + ColorStop::new(Color::NONE, Val::Percent(100.)), + ], + color_space: InterpolationColorSpace::Srgba, + })]}) + Children [( + // Text container + Node { + display: Display::Flex, + position_type: PositionType::Absolute, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + } + InheritableThemeTextColor(tokens::SLIDER_TEXT) + InheritableFont { + font: fonts::MONO, + font_size: size::SMALL_FONT, + weight: FontWeight::NORMAL, + } + Children [(Text("10.0") ThemedText SliderValueText)] + )] + } } } +/// Marker for the text +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +struct SliderValueText; + /// Spawn a new slider widget. /// /// # Arguments @@ -145,7 +145,7 @@ pub fn slider(props: SliderProps) -> impl Scene { /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity #[deprecated(since = "0.19.0", note = "Use the slider() BSN function")] -pub fn slider_bundle(props: SliderProps, overrides: B) -> impl Bundle { +pub fn slider_bundle(props: FeathersSliderProps, overrides: B) -> impl Bundle { ( Node { height: size::ROW_HEIGHT, @@ -161,7 +161,7 @@ pub fn slider_bundle(props: SliderProps, overrides: B) -> impl Bundle track_click: TrackClick::Drag, orientation: SliderOrientation::Horizontal, }, - SliderStyle, + FeathersSlider, SliderValue(props.value), SliderRange::new(props.min, props.max), EntityCursor::System(bevy_window::SystemCursorIcon::EwResize), @@ -210,7 +210,7 @@ fn update_slider_styles( &mut BackgroundGradient, ), ( - With, + With, Or<( Spawned, Added, @@ -244,7 +244,7 @@ fn update_slider_styles_remove( &Hovered, &mut BackgroundGradient, ), - With, + With, >, mut removed_disabled: RemovedComponents, mut remove_pressed: RemovedComponents, @@ -328,7 +328,7 @@ fn update_slider_pos( &mut BackgroundGradient, ), ( - With, + With, Or<( Changed, Changed, diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index 5ff3b4e2b9105..105cde27303f8 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -29,98 +29,102 @@ use crate::{ tokens, }; -/// Marker to indicate a text input widget with feathers styling. -#[derive(Component, Default, Clone)] -struct FeathersTextInputContainer; - -/// Marker to indicate the inner part of the text input widget. -#[derive(Component, Default, Clone)] -struct FeathersTextInput; - -/// Parameters for the text input template, passed to [`text_input`] function. -#[derive(Default, Clone)] -pub struct TextInputProps { - /// Visible width - pub visible_width: Option, - /// Max characters - pub max_characters: Option, -} - /// Decorative frame around a text input widget. This is a separate entity to allow icons /// (such as "search" or "clear") to be inserted adjacent to the input. -pub fn text_input_container() -> impl Scene { - bsn! { - Node { - height: size::ROW_HEIGHT, - display: Display::Flex, - justify_content: JustifyContent::Center, - align_items: AlignItems::Center, - padding: UiRect { - right: px(3.0), - }, - border: UiRect { - left: px(3.0) - }, - flex_grow: 1.0, - border_radius: {BorderRadius::all(px(4.0))}, - column_gap: px(4), - } - FeathersTextInputContainer - FocusWithinIndicator - ThemeBackgroundColor(tokens::TEXT_INPUT_BG) - InheritableThemeTextColor(tokens::TEXT_INPUT_TEXT) - InheritableFont { - font: fonts::REGULAR, - font_size: size::COMPACT_FONT, - weight: FontWeight::NORMAL, +/// +/// This is spawnable by inheriting it as a "scene component". +#[derive(Component, Default, Clone)] +#[component(scene)] +pub struct FeathersTextInputContainer; + +impl FeathersTextInputContainer { + fn scene() -> impl Scene { + bsn! { + Node { + height: size::ROW_HEIGHT, + display: Display::Flex, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + padding: UiRect { + right: px(3.0), + }, + border: UiRect { + left: px(3.0) + }, + flex_grow: 1.0, + border_radius: {BorderRadius::all(px(4.0))}, + column_gap: px(4), + } + FeathersTextInputContainer + FocusWithinIndicator + ThemeBackgroundColor(tokens::TEXT_INPUT_BG) + InheritableThemeTextColor(tokens::TEXT_INPUT_TEXT) + InheritableFont { + font: fonts::REGULAR, + font_size: size::COMPACT_FONT, + weight: FontWeight::NORMAL, + } } } } -/// Scene function to spawn a text input. For proper styling, this should be enclosed by a -/// `text_input_container`. +/// Scene function to spawn a text input. For proper styling, this should be enclosed by a [`FeathersTextInputContainer`]. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersTextInputProps`]. /// /// ```ignore -/// :text_input_container +/// :FeathersTextInputContainer /// Children [ -/// text_input(props) +/// :FeathersTextInput /// ] /// ``` -/// -/// # Arguments -/// * `props` - construction properties for the text input. -pub fn text_input(props: TextInputProps) -> impl Scene { - bsn! { - Node { - flex_grow: { - if props.visible_width.is_some() { - 0. - } else { - 1. - } - } , - } - FeathersTextInput - EditableText { - cursor_width: 0.3, - visible_width: {props.visible_width}, - max_characters: {props.max_characters}, - } - TextLayout { - linebreak: LineBreak::NoWrap, - } - TabIndex(0) - template(|ctx| { - Ok(TextFont { - font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), - font_size: size::COMPACT_FONT, - weight: FontWeight::NORMAL, - ..Default::default() +#[derive(Component, Default, Clone)] +#[component(scene_props = FeathersTextInputProps)] +pub struct FeathersTextInput; + +/// Props used to construct the [`FeathersTextInput`] scene. +#[derive(Default, Clone)] +pub struct FeathersTextInputProps { + /// Visible width + pub visible_width: Option, + /// Max characters + pub max_characters: Option, +} + +impl FeathersTextInput { + fn scene(props: FeathersTextInputProps) -> impl Scene { + bsn! { + Node { + flex_grow: { + if props.visible_width.is_some() { + 0. + } else { + 1. + } + } , + } + FeathersTextInput + EditableText { + cursor_width: 0.3, + visible_width: {props.visible_width}, + max_characters: {props.max_characters}, + } + TextLayout { + linebreak: LineBreak::NoWrap, + } + TabIndex(0) + template(|ctx| { + Ok(TextFont { + font: FontSource::Handle(ctx.resource::().load(fonts::REGULAR)), + font_size: size::COMPACT_FONT, + weight: FontWeight::NORMAL, + ..Default::default() + }) }) - }) - PropagateOver - EntityCursor::System(bevy_window::SystemCursorIcon::Text) - TextCursorStyle::default() + PropagateOver + EntityCursor::System(bevy_window::SystemCursorIcon::Text) + TextCursorStyle::default() + } } } diff --git a/crates/bevy_feathers/src/controls/toggle_switch.rs b/crates/bevy_feathers/src/controls/toggle_switch.rs index 1a84b835440aa..54d4d48af6e6c 100644 --- a/crates/bevy_feathers/src/controls/toggle_switch.rs +++ b/crates/bevy_feathers/src/controls/toggle_switch.rs @@ -31,56 +31,60 @@ use crate::{ tokens, }; -/// Marker for the toggle switch outline -#[derive(Component, Default, Clone, Reflect)] -#[reflect(Component, Clone, Default)] -struct ToggleSwitchOutline; - -/// Marker for the toggle switch slide -#[derive(Component, Default, Clone, Reflect)] -#[reflect(Component, Clone, Default)] -struct ToggleSwitchSlide; - -/// Scene function to spawn a toggle switch. +/// A toggle switch widget. +/// +/// This is spawnable by inheriting it as a "scene component". /// /// # Emitted events /// * [`bevy_ui_widgets::ValueChange`] with the new value when the toggle switch changes state. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the bundle -pub fn toggle_switch() -> impl Scene { - bsn! { - Node { - width: size::TOGGLE_WIDTH, - height: size::TOGGLE_HEIGHT, - border: UiRect::all(Val::Px(2.0)), - border_radius: BorderRadius::all(Val::Px(5.0)), - } - Checkbox - ToggleSwitchOutline - ThemeBackgroundColor(tokens::SWITCH_BG) - ThemeBorderColor(tokens::SWITCH_BORDER) - AccessibilityNode(accesskit::Node::new(Role::Switch)) - Hovered - EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) - TabIndex(0) - FocusIndicator - Children [( +#[derive(Component, Default, Clone, Reflect)] +#[component(scene)] +#[reflect(Component, Clone, Default)] +pub struct FeathersToggleSwitch; + +impl FeathersToggleSwitch { + fn scene() -> impl Scene { + bsn! { Node { - position_type: PositionType::Absolute, - left: Val::Percent(0.), - top: Val::Px(0.), - bottom: Val::Px(0.), - width: Val::Percent(50.), + width: size::TOGGLE_WIDTH, + height: size::TOGGLE_HEIGHT, border: UiRect::all(Val::Px(2.0)), - border_radius: BorderRadius::all(Val::Px(3.0)), + border_radius: BorderRadius::all(Val::Px(5.0)), } - ToggleSwitchSlide - ThemeBackgroundColor(tokens::SWITCH_SLIDE_BG) - ThemeBorderColor(tokens::SWITCH_SLIDE_BORDER) - )] + Checkbox + FeathersToggleSwitch + ThemeBackgroundColor(tokens::SWITCH_BG) + ThemeBorderColor(tokens::SWITCH_BORDER) + AccessibilityNode(accesskit::Node::new(Role::Switch)) + Hovered + EntityCursor::System(bevy_window::SystemCursorIcon::Pointer) + TabIndex(0) + FocusIndicator + Children [( + Node { + position_type: PositionType::Absolute, + left: Val::Percent(0.), + top: Val::Px(0.), + bottom: Val::Px(0.), + width: Val::Percent(50.), + border: UiRect::all(Val::Px(2.0)), + border_radius: BorderRadius::all(Val::Px(3.0)), + } + ToggleSwitchSlide + ThemeBackgroundColor(tokens::SWITCH_SLIDE_BG) + ThemeBorderColor(tokens::SWITCH_SLIDE_BORDER) + )] + } } } +/// Marker for the toggle switch slide +#[derive(Component, Default, Clone, Reflect)] +#[reflect(Component, Clone, Default)] +struct ToggleSwitchSlide; + /// Template function to spawn a toggle switch. /// /// # Arguments @@ -102,7 +106,7 @@ pub fn toggle_switch_bundle(overrides: B) -> impl Bundle { ..Default::default() }, Checkbox, - ToggleSwitchOutline, + FeathersToggleSwitch, ThemeBackgroundColor(tokens::SWITCH_BG), ThemeBorderColor(tokens::SWITCH_BORDER), AccessibilityNode(accesskit::Node::new(Role::Switch)), @@ -142,7 +146,7 @@ fn update_switch_styles( &ThemeBorderColor, ), ( - With, + With, Or<( Changed, Added, @@ -208,7 +212,7 @@ fn update_switch_styles_remove( &ThemeBackgroundColor, &ThemeBorderColor, ), - With, + With, >, q_children: Query<&Children>, mut q_slide: Query< diff --git a/crates/bevy_feathers/src/controls/virtual_keyboard.rs b/crates/bevy_feathers/src/controls/virtual_keyboard.rs index 587288ca31588..429f1e387ccbe 100644 --- a/crates/bevy_feathers/src/controls/virtual_keyboard.rs +++ b/crates/bevy_feathers/src/controls/virtual_keyboard.rs @@ -1,3 +1,5 @@ +use core::marker::PhantomData; + use bevy_ecs::prelude::*; use bevy_input_focus::tab_navigation::TabGroup; use bevy_scene::prelude::*; @@ -6,77 +8,97 @@ use bevy_ui::Val; use bevy_ui::{widget::Text, FlexDirection}; use bevy_ui_widgets::{observe, Activate}; -use crate::controls::button::{button, ButtonBundleProps, ButtonProps}; +use crate::controls::button::ButtonBundleProps; use crate::controls::button_bundle; +use crate::controls::FeathersButton; -/// Fired whenever a virtual key is pressed. -#[derive(EntityEvent)] -pub struct VirtualKeyPressed { - /// The virtual keyboard entity - pub entity: Entity, - /// The pressed virtual key - pub key: T, -} - -/// Function to spawn a virtual keyboard +/// A virtual keyboard widget. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`VirtualKeyboardProps`]. /// /// # Emitted events /// * [`crate::controls::VirtualKeyPressed`] when a virtual key on the keyboard is un-pressed. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -pub fn virtual_keyboard(keys: impl Iterator> + Send + Sync + 'static) -> impl Scene -where - T: AsRef + Clone + Send + Sync + 'static, -{ - let keys = Vec::from_iter(keys.map(move |row| { - let key_row = Vec::from_iter(row.into_iter().map(move |key| { - let key_clone = key.clone(); +#[derive(Component, FromTemplate)] +#[component(scene_props = VirtualKeyboardProps)] +pub struct VirtualKeyboard + Clone + Send + Sync + 'static>(PhantomData T>); + +/// Props used to construct the [`VirtualKeyboard`] scene. +pub struct VirtualKeyboardProps { + /// The keys on the keyboard, by row. + pub keys: Vec>, +} + +impl Default for VirtualKeyboardProps { + fn default() -> Self { + Self { + keys: Default::default(), + } + } +} + +impl + Clone + Send + Sync + 'static> VirtualKeyboard { + fn scene(props: VirtualKeyboardProps) -> impl Scene { + let keys = Vec::from_iter(props.keys.into_iter().map(move |row| { + let key_row = Vec::from_iter(row.into_iter().map(move |key| { + let key_clone = key.clone(); + bsn! { + :FeathersButton + Node { + flex_grow: 1.0, + } + on( + move |activate: On, + mut commands: Commands, + query: Query<&ChildOf>| + -> Result { + let virtual_keyboard = + query.get(query.get(activate.entity)?.parent())?.parent(); + commands.trigger(VirtualKeyPressed { + entity: virtual_keyboard, + key: key.clone(), + }); + Ok(()) + }, + ) + Children [ + Text::new(key_clone.as_ref()) + ] + } + })); bsn! { - button(ButtonProps::default()) Node { - flex_grow: 1.0, + flex_direction: FlexDirection::Row, + column_gap: Val::Px(4.), } - on( - move |activate: On, - mut commands: Commands, - query: Query<&ChildOf>| - -> Result { - let virtual_keyboard = - query.get(query.get(activate.entity)?.parent())?.parent(); - commands.trigger(VirtualKeyPressed { - entity: virtual_keyboard, - key: key.clone(), - }); - Ok(()) - }, - ) Children [ - Text::new(key_clone.as_ref()) + {key_row} ] } })); bsn! { Node { - flex_direction: FlexDirection::Row, - column_gap: Val::Px(4.), + flex_direction: FlexDirection::Column, + row_gap: Val::Px(4.), } + TabGroup::new(0) Children [ - {key_row} + {keys} ] } - })); - bsn! { - Node { - flex_direction: FlexDirection::Column, - row_gap: Val::Px(4.), - } - TabGroup::new(0) - Children [ - {keys} - ] } } +/// Fired whenever a virtual key is pressed. +#[derive(EntityEvent)] +pub struct VirtualKeyPressed { + /// The virtual keyboard entity + pub entity: Entity, + /// The pressed virtual key + pub key: T, +} + /// Function to spawn a virtual keyboard /// /// # Emitted events diff --git a/crates/bevy_feathers/src/display/icon.rs b/crates/bevy_feathers/src/display/icon.rs index 70a137b4c9940..71184deea8cdb 100644 --- a/crates/bevy_feathers/src/display/icon.rs +++ b/crates/bevy_feathers/src/display/icon.rs @@ -1,6 +1,4 @@ //! BSN Scene for loading images and displaying them as [`ImageNode`]s. -use bevy_asset::AssetServer; -use bevy_ecs::template::template; use bevy_scene::{bsn, Scene}; use bevy_ui::{widget::ImageNode, Node, Val}; @@ -10,9 +8,8 @@ pub fn icon(image: &'static str) -> impl Scene { Node { height: Val::Px(14.0), } - template(move |entity| { - let handle = entity.resource::().load(image); - Ok(ImageNode::new(handle)) - }) + ImageNode { + image: image + } } } diff --git a/crates/bevy_render/src/view/mod.rs b/crates/bevy_render/src/view/mod.rs index 38f5ec37e76ad..6c55afaec7b80 100644 --- a/crates/bevy_render/src/view/mod.rs +++ b/crates/bevy_render/src/view/mod.rs @@ -28,7 +28,7 @@ use alloc::sync::{Arc, Weak}; use bevy_app::{App, Plugin}; use bevy_color::{LinearRgba, Oklaba, Srgba}; use bevy_derive::{Deref, DerefMut}; -use bevy_ecs::prelude::*; +use bevy_ecs::{prelude::*, VariantDefaults}; use bevy_image::ToExtents; use bevy_math::{mat3, vec2, vec3, Mat3, Mat4, UVec4, Vec2, Vec3, Vec4, Vec4Swizzles}; use bevy_platform::collections::{hash_map::Entry, HashMap}; @@ -231,6 +231,7 @@ impl Plugin for ViewPlugin { Reflect, PartialEq, PartialOrd, + VariantDefaults, Eq, Hash, Debug, diff --git a/crates/bevy_scene/Cargo.toml b/crates/bevy_scene/Cargo.toml index 4f5143d00949d..bd086f6450a77 100644 --- a/crates/bevy_scene/Cargo.toml +++ b/crates/bevy_scene/Cargo.toml @@ -14,7 +14,9 @@ bevy_scene_macros = { path = "macros", version = "0.19.0-dev" } bevy_app = { path = "../bevy_app", version = "0.19.0-dev" } bevy_asset = { path = "../bevy_asset", version = "0.19.0-dev" } bevy_derive = { path = "../bevy_derive", version = "0.19.0-dev" } -bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } +bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev", features = [ + "derive_scene", +] } bevy_log = { path = "../bevy_log", version = "0.19.0-dev" } bevy_platform = { path = "../bevy_platform", version = "0.19.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.19.0-dev" } diff --git a/crates/bevy_scene/macros/Cargo.toml b/crates/bevy_scene/macros/Cargo.toml index de6dcb477ccd9..6f9b1bdd12984 100644 --- a/crates/bevy_scene/macros/Cargo.toml +++ b/crates/bevy_scene/macros/Cargo.toml @@ -13,7 +13,6 @@ proc-macro = true [dependencies] bevy_macro_utils = { path = "../../bevy_macro_utils", version = "0.19.0-dev" } - syn = { version = "2.0", features = ["full", "extra-traits"] } proc-macro2 = "1.0" quote = "1.0" diff --git a/crates/bevy_scene/macros/src/bsn/codegen.rs b/crates/bevy_scene/macros/src/bsn/codegen.rs index 0ce1763744479..e224bd0f52237 100644 --- a/crates/bevy_scene/macros/src/bsn/codegen.rs +++ b/crates/bevy_scene/macros/src/bsn/codegen.rs @@ -146,6 +146,25 @@ impl Bsn { } } +fn from_template_patch(ctx: &mut BsnCodegenCtx, ty: &BsnType) -> syn::Result { + let mut assigns = Vec::new(); + let target = PatchTarget { + path: &[Member::Named(Ident::new( + "value", + proc_macro2::Span::call_site(), + ))], + is_ref: true, + }; + ty.to_patch_tokens(ctx, &mut assigns, true, target, false)?; + let path = &ty.path; + let bevy_scene = ctx.bevy_scene; + Ok(quote! { + <#path as #bevy_scene::PatchFromTemplate>::patch(move |value, _context| { + #(#assigns)* + }) + }) +} + impl BsnEntry { fn try_to_tokens(&self, ctx: &mut BsnCodegenCtx) -> syn::Result { let (bevy_scene, bevy_ecs) = (ctx.bevy_scene, ctx.bevy_ecs); @@ -160,31 +179,16 @@ impl BsnEntry { ))], is_ref: true, }; - ty.to_patch_tokens(ctx, &mut assigns, true, target)?; + ty.to_patch_tokens(ctx, &mut assigns, true, target, false)?; let path = &ty.path; + let bevy_scene = ctx.bevy_scene; Ok(quote! { <#path as #bevy_scene::PatchTemplate>::patch_template(move |value, _context| { #(#assigns)* }) }) } - BsnEntry::FromTemplatePatch(ty) => { - let mut assigns = Vec::new(); - let target = PatchTarget { - path: &[Member::Named(Ident::new( - "value", - proc_macro2::Span::call_site(), - ))], - is_ref: true, - }; - ty.to_patch_tokens(ctx, &mut assigns, true, target)?; - let path = &ty.path; - Ok(quote! { - <#path as #bevy_scene::PatchFromTemplate>::patch(move |value, _context| { - #(#assigns)* - }) - }) - } + BsnEntry::FromTemplatePatch(ty) => from_template_patch(ctx, ty), BsnEntry::TemplateConst { type_path, const_ident, @@ -222,14 +226,43 @@ impl BsnEntry { ::Relationship, _>::new(#scenes) }) } - BsnEntry::InheritedScene(s) => match s { - BsnInheritedScene::Asset(lit) => Ok(quote! { + BsnEntry::InheritedScene(s) => Ok(match s { + BsnInheritedScene::Asset(lit) => quote! { #bevy_scene::InheritSceneAsset::from(#lit) - }), - BsnInheritedScene::Fn { function, args } => Ok(quote! { - #bevy_scene::SceneScope(#function(#args)) - }), - }, + }, + BsnInheritedScene::Fn { path, args } => quote! { + #bevy_scene::SceneScope(#path(#args)) + }, + BsnInheritedScene::Type(bsn_type) => { + // TODO: this can and should use a simpler codegen path than BsnType::to_patch_tokens, + // which imposes constraints like requiring the type to impl FromTemplate, and requiring + // enums to have VariantDefault. + let mut assignments = Vec::new(); + let props = format_ident!("props"); + let props_ref = format_ident!("props_ref"); + bsn_type.to_patch_tokens( + ctx, + &mut assignments, + false, + PatchTarget { + path: &[Member::Named(props_ref.clone())], + is_ref: true, + }, + true, + )?; + let type_path = &bsn_type.path; + let from_template_patch = from_template_patch(ctx, bsn_type)?; + quote! {{ + let mut #props = <<#type_path as #bevy_scene::SceneConstructor>::Props as Default>::default(); + let #props_ref = &mut #props; + #(#assignments)* + (<#type_path as #bevy_scene::SceneConstructor>::scene(#props), #from_template_patch) + }} + } + BsnInheritedScene::Expression(tokens) => quote! { + #tokens + }, + }), BsnEntry::Name(ident) => { let (name, index) = (ident.to_string(), ctx.entity_refs.get(ident.to_string())); Ok(quote! { @@ -253,6 +286,7 @@ impl BsnType { assignments: &mut Vec, is_root: bool, target: PatchTarget, + is_props: bool, ) -> syn::Result<()> { if !is_root { let (path, bevy_scene) = (&self.path, ctx.bevy_scene); @@ -260,9 +294,13 @@ impl BsnType { } if let Some(variant) = &self.enum_variant { - self.push_enum_patch(ctx, variant, assignments, target)?; + if is_props { + self.push_struct_patch(ctx, assignments, target, true)?; + } else { + self.push_enum_patch(ctx, variant, assignments, target)?; + } } else { - self.push_struct_patch(ctx, assignments, target)?; + self.push_struct_patch(ctx, assignments, target, is_props)?; } Ok(()) @@ -348,12 +386,16 @@ impl BsnType { ctx: &mut BsnCodegenCtx, assignments: &mut Vec, target: PatchTarget, + is_props: bool, ) -> syn::Result<()> { match &self.fields { BsnFields::Named(fields) => { let mut seen = HashSet::with_capacity(fields.len()); for field in fields { + if is_props != field.is_prop { + continue; + } let field_name = &field.name; if !seen.insert(field_name.to_string()) { ctx.errors.push(syn::Error::new_spanned( @@ -370,10 +412,16 @@ impl BsnType { )); } + let path = if field.is_prop { + &[Member::Named(format_ident!("props"))] + } else { + target.path + }; + self.process_field( ctx, assignments, - target.path, + path, Member::Named(field_name.clone()), field.value.as_ref(), )?; @@ -453,6 +501,7 @@ impl BsnType { path: &new_path, is_ref: false, }, + false, )?; } } @@ -484,6 +533,7 @@ impl BsnType { path: &[Member::Named(bind_name.clone())], is_ref: true, }, + false, )?; return Ok(quote! {#(#type_assigns)*}); } @@ -541,7 +591,13 @@ impl ToTokens for BsnValue { BsnValue::Closure(c) => quote! {(#c).into()}.to_tokens(tokens), BsnValue::Ident(i) => quote! {(#i).into()}.to_tokens(tokens), BsnValue::Lit(Lit::Str(s)) => quote! {#s.into()}.to_tokens(tokens), - BsnValue::Lit(l) => l.to_tokens(tokens), + BsnValue::Lit(l) => { + if l.suffix().is_empty() { + l.to_tokens(tokens) + } else { + quote! {(#l).into()}.to_tokens(tokens) + } + } BsnValue::Tuple(t) => { let inner = t.0.iter(); quote! {(#(#inner),*)}.to_tokens(tokens); @@ -603,10 +659,12 @@ mod tests { BsnNamedField { name: parse_quote!(x), value: Some(BsnValue::Expr(quote!({}))), + is_prop: false, }, BsnNamedField { name: parse_quote!(x), value: Some(BsnValue::Expr(quote!({}))), + is_prop: false, }, ]), }; @@ -618,6 +676,7 @@ mod tests { path: &[], is_ref: false, }, + false, ); assert!(res.is_ok()); @@ -638,6 +697,7 @@ mod tests { path: parse_quote!(Parent), enum_variant: None, fields: BsnFields::Named(vec![BsnNamedField { + is_prop: false, name: parse_quote!(child_field), value: Some(BsnValue::Type(BsnType { path: parse_quote!(Child), @@ -646,10 +706,12 @@ mod tests { BsnNamedField { name: parse_quote!(x), value: Some(BsnValue::Expr(quote!({}))), + is_prop: false, }, BsnNamedField { name: parse_quote!(x), value: Some(BsnValue::Expr(quote!({}))), + is_prop: false, }, ]), })), @@ -664,6 +726,7 @@ mod tests { path: &[], is_ref: false, }, + false, ); assert!(res.is_ok()); @@ -684,6 +747,7 @@ mod tests { path: parse_quote!(Transform), enum_variant: None, fields: BsnFields::Named(vec![BsnNamedField { + is_prop: false, name: parse_quote!(x), value: None, }]), @@ -696,6 +760,7 @@ mod tests { path: &[Member::Named(parse_quote!(value))], is_ref: false, }, + false, ); assert!(res.is_ok()); @@ -718,10 +783,12 @@ mod tests { enum_variant: Some(parse_quote!(Variant)), fields: BsnFields::Named(vec![ BsnNamedField { + is_prop: false, name: parse_quote!(x), value: Some(BsnValue::Expr(quote!(1))), }, BsnNamedField { + is_prop: false, name: parse_quote!(x), value: Some(BsnValue::Expr(quote!(2))), }, diff --git a/crates/bevy_scene/macros/src/bsn/parse.rs b/crates/bevy_scene/macros/src/bsn/parse.rs index 211c899ce3ab7..21e4ad1cd11b0 100644 --- a/crates/bevy_scene/macros/src/bsn/parse.rs +++ b/crates/bevy_scene/macros/src/bsn/parse.rs @@ -223,16 +223,30 @@ impl BsnInheritedScene { Ok(if input.peek(LitStr) { let path = input.parse::()?; BsnInheritedScene::Asset(path) + } else if input.peek(Brace) { + BsnInheritedScene::Expression(braced_tokens(input)?) } else { - let function = input.parse::()?; - let args = if input.peek(Paren) { - let content; - parenthesized!(content in input); - Some(content.parse_terminated(Expr::parse, Token![,])?) - } else { - None - }; - BsnInheritedScene::Fn { function, args } + // PERF: do we really need this fork here? + let path = input.fork().parse::()?; + match PathType::new(&path) { + PathType::Type | PathType::Enum => { + BsnInheritedScene::Type(input.parse::()?) + } + PathType::Function => { + let path = input.parse::()?; + let args = if input.peek(Paren) { + let content; + parenthesized!(content in input); + Some(content.parse_terminated(Expr::parse, Token![,])?) + } else { + None + }; + BsnInheritedScene::Fn { path, args } + } + _ => { + todo!() + } + } }) } } @@ -301,6 +315,12 @@ impl Parse for BsnFields { impl Parse for BsnNamedField { fn parse(input: ParseStream) -> Result { + let is_prop = if input.peek(At) { + input.parse::()?; + true + } else { + false + }; let name = input.parse::()?; let value = if input.peek(Colon) { input.parse::()?; @@ -313,7 +333,11 @@ impl Parse for BsnNamedField { } else { None }; - Ok(BsnNamedField { name, value }) + Ok(BsnNamedField { + name, + value, + is_prop, + }) } } diff --git a/crates/bevy_scene/macros/src/bsn/types.rs b/crates/bevy_scene/macros/src/bsn/types.rs index dbfc3ad1058ed..0787207eff7d2 100644 --- a/crates/bevy_scene/macros/src/bsn/types.rs +++ b/crates/bevy_scene/macros/src/bsn/types.rs @@ -55,9 +55,11 @@ pub enum BsnSceneListItem { pub enum BsnInheritedScene { Asset(LitStr), Fn { - function: Path, + path: Path, args: Option>, }, + Type(BsnType), + Expression(TokenStream), } #[derive(Debug)] @@ -78,6 +80,7 @@ pub struct BsnTuple(pub Vec); #[derive(Debug)] pub struct BsnNamedField { + pub is_prop: bool, pub name: Ident, /// This is an Option to enable autocomplete when the field name is being typed /// To improve autocomplete further we'll need to forgo a lot of the syn parsing diff --git a/crates/bevy_scene/src/lib.rs b/crates/bevy_scene/src/lib.rs index 98a2a76cbe49a..6bbe0cfa64dea 100644 --- a/crates/bevy_scene/src/lib.rs +++ b/crates/bevy_scene/src/lib.rs @@ -656,6 +656,12 @@ mod tests { } } + #[derive(Component, Default, Clone)] + #[component(scene = "a.bsn")] + struct AWidget { + value: usize, + } + let mut app = App::new(); let dir = Dir::default(); let dir_clone = dir.clone(); @@ -728,6 +734,27 @@ mod tests { let y = world.entity(children[1]); let name = y.get::().unwrap(); assert_eq!(name.as_str(), "Y"); + + // "a.bsn" as AWidget's "component scene" + let id = world + .spawn_scene(bsn! {:AWidget { value: 2 }}) + .unwrap() + .id(); + let root = world.entity(id); + + let a_widget = root.get::().unwrap(); + assert_eq!(a_widget.value, 2); + let position = root.get::().unwrap(); + assert_eq!(position.x, 0.); + assert_eq!(position.y, 2.); + assert_eq!(position.z, 0.); + + let children = root.get::().unwrap(); + assert_eq!(children.len(), 1); + + let x = world.entity(children[0]); + let name = x.get::().unwrap(); + assert_eq!(name.as_str(), "X"); } #[test] @@ -1403,6 +1430,111 @@ mod tests { app.update(); } + #[test] + fn component_scene() { + #[derive(Component, Default, Clone)] + #[component(scene)] + struct Widget; + + impl Widget { + fn scene() -> impl Scene { + bsn! {Name("widget")} + } + } + + let mut app = test_app(); + let world = app.world_mut(); + let entity = world.spawn_scene(bsn! {:Widget}).unwrap(); + assert_eq!(entity.get::().unwrap().as_str(), "widget"); + assert!(entity.contains::()); + + #[derive(Component, Default, Clone)] + #[component(scene = Widget::scene)] + struct OtherWidget; + let entity = world.spawn_scene(bsn! {:OtherWidget}).unwrap(); + assert_eq!(entity.get::().unwrap().as_str(), "widget"); + assert!(entity.contains::()); + assert!( + !entity.contains::(), + "This reuses the Widget::scene function, but that scene does not explicitly add Widget" + ); + } + + #[test] + fn component_scene_props() { + #[derive(Component, Default, Clone)] + #[component(scene_props = WidgetProps)] + struct Widget { + value: usize, + } + + #[derive(Default)] + struct WidgetProps { + children: usize, + } + + impl Widget { + fn scene(props: WidgetProps) -> impl Scene { + let children = (0..props.children) + .map(|i| { + bsn! { + Name({format!("Child{i}")}) + } + }) + .collect::>(); + bsn! { + Children [ + {children} + ] + } + } + } + + let mut app = test_app(); + let world = app.world_mut(); + let entity = world + .spawn_scene(bsn! {:Widget { + @children: 2, + value: 10, + }}) + .unwrap(); + assert_eq!(entity.get::().unwrap().value, 10); + assert_eq!(entity.get::().unwrap().len(), 2); + + #[derive(Component, Default, Clone)] + #[component(scene = Widget::scene, scene_props = WidgetProps)] + struct OtherWidget { + value: usize, + } + + let entity = world + .spawn_scene(bsn! {:OtherWidget { + @children: 2, + value: 10, + }}) + .unwrap(); + assert_eq!(entity.get::().unwrap().value, 10); + assert_eq!(entity.get::().unwrap().len(), 2); + } + + #[test] + fn scene_without_explicit_component_still_spawns_component() { + #[derive(Component, Default, Clone)] + #[component(scene)] + struct Widget; + + impl Widget { + fn scene() -> impl Scene { + bsn! {} + } + } + + let mut app = test_app(); + let world = app.world_mut(); + let entity = world.spawn_scene(bsn! {:Widget}).unwrap(); + assert!(entity.contains::()); + } + #[test] fn drop_is_called_for_uninserted_components() { #[derive(Component, FromTemplate)] diff --git a/crates/bevy_scene/src/scene.rs b/crates/bevy_scene/src/scene.rs index 11d8c3553be59..67a9cab155779 100644 --- a/crates/bevy_scene/src/scene.rs +++ b/crates/bevy_scene/src/scene.rs @@ -5,12 +5,14 @@ use bevy_ecs::{ component::Component, error::Result, event::EntityEvent, + lifecycle::HookContext, name::Name, relationship::Relationship, system::IntoObserverSystem, template::{ EntityScopes, FnTemplate, FromTemplate, ScopedEntityIndex, Template, TemplateContext, }, + world::DeferredWorld, }; use core::{any::TypeId, marker::PhantomData}; use thiserror::Error; @@ -540,3 +542,94 @@ pub fn on, E: EntityEvent, B: Bundle, M: 'static> ) -> OnTemplate { OnTemplate(observer, PhantomData) } + +/// Implemented for [`Component`]s that have an associated [`Scene`], which can be constructed +/// with [`Self::Props`]. +/// +/// In general, developers should not implement this manually. Instead, they should specify `#[component(scene)]` +/// in the [`Component`] derive, which will derive this trait and add additional protections and assurances. +/// +/// See the "Scene Components" sections of the [`Scene`] docs to see how this is used in practice. +pub trait SceneConstructor: FromTemplate +where + ::Output: Component, +{ + /// The "properties" passed into [`Self::scene`] to build the final scene. + type Props: Default; + + /// A function that uses the given `props` to produce a [`Scene`] + fn scene(props: Self::Props) -> impl Scene; +} + +impl From> for Box { + fn from(value: SceneScope) -> Self { + Box::new(value) + } +} + +impl From> for Box { + fn from(value: SceneListScope) -> Self { + Box::new(value) + } +} + +impl From> for Box { + fn from(value: SceneScope) -> Self { + Box::new(value) + } +} + +/// A [`Scene`] that initializes a template if it doesn't yet exist. +#[derive(Default)] +pub struct InitTemplate(PhantomData); + +impl + Default + Send + Sync + 'static> Scene for InitTemplate { + fn resolve( + self, + context: &mut ResolveContext, + scene: &mut ResolvedScene, + ) -> Result<(), ResolveSceneError> { + let _ = scene.get_or_insert_template::(context); + Ok(()) + } +} + +/// A [`Component`] that must always be spawned with a [`Scene`]. +#[derive(Component, Default, Clone)] +#[cfg_attr(debug_assertions, component(on_add))] +pub struct SceneComponent { + spawned_from_scene: bool, + #[cfg(debug_assertions)] + component_name: &'static str, +} + +impl SceneComponent { + /// Creates a new [`SceneComponent`] for the given type `C`. + pub fn new(spawned_from_scene: bool) -> Self { + SceneComponent { + spawned_from_scene, + #[cfg(debug_assertions)] + component_name: core::any::type_name::(), + } + } +} + +impl SceneComponent { + #[cfg(debug_assertions)] + fn on_add(world: DeferredWorld, context: HookContext) { + if let Ok(entity) = world.get_entity(context.entity) + && let Some(component) = entity.get::() + && !component.spawned_from_scene + { + tracing::error!( + "Entity {} was spawned with the \"scene component\" {}, but without its scene. \ + Scene components should not be spawned directly as components. Instead, they \ + should be spawned as \"scenes\" using world.spawn_scene or commands.spawn_scene. \ + Scene components should be inherited using `:{}` syntax in BSN.", + context.entity, + component.component_name, + component.component_name + ); + } + } +} diff --git a/crates/bevy_scene/src/scene_list.rs b/crates/bevy_scene/src/scene_list.rs index 0e70a123c1f46..61739470a2c15 100644 --- a/crates/bevy_scene/src/scene_list.rs +++ b/crates/bevy_scene/src/scene_list.rs @@ -1,4 +1,6 @@ -use crate::{ResolveContext, ResolveSceneError, ResolvedScene, Scene, SceneDependencies}; +use crate::{ + ResolveContext, ResolveSceneError, ResolvedScene, Scene, SceneDependencies, SceneScope, +}; use variadics_please::all_tuples; /// This behaves like a list of [`Scene`], where each entry in the list is a new entity (see [`Scene`] for more details). @@ -156,3 +158,20 @@ impl SceneList for Vec { } } } + +impl SceneList for SceneScope { + fn resolve_list( + self, + context: &mut ResolveContext, + scenes: &mut Vec, + ) -> Result<(), ResolveSceneError> { + let mut resolved_scene = ResolvedScene::default(); + self.0.resolve(context, &mut resolved_scene)?; + scenes.push(resolved_scene); + Ok(()) + } + + fn register_dependencies(&self, dependencies: &mut SceneDependencies) { + self.0.register_dependencies(dependencies); + } +} diff --git a/examples/large_scenes/bevy_city/src/main.rs b/examples/large_scenes/bevy_city/src/main.rs index 616ed5fa8e321..aa6233a1b2ee2 100644 --- a/examples/large_scenes/bevy_city/src/main.rs +++ b/examples/large_scenes/bevy_city/src/main.rs @@ -29,11 +29,11 @@ use bevy::{ world_serialization::WorldInstanceReady, }; +use crate::generate_city::spawn_city; use crate::{ assets::{merge_car_meshes, strip_base_url}, - settings::Settings, + settings::{settings_ui, Settings}, }; -use crate::{generate_city::spawn_city, settings::setup_settings_ui}; mod assets; mod generate_city; @@ -90,15 +90,15 @@ fn main() { .add_message::() .add_message::() .add_message::() - .add_systems(Startup, (setup, spawn_atmosphere, load_assets)) + .add_systems(Startup, (scene.spawn(), spawn_atmosphere, load_assets)) .add_systems( Update, ( simulate_cars, - loading_screen, + update_loading_screen, process_assets.run_if(on_message::), on_city_assets_ready.run_if(on_message::), - (add_no_cpu_culling, on_city_spawned, setup_settings_ui) + (add_no_cpu_culling, on_city_spawned, settings_ui.spawn()) .run_if(on_message::), ), ) @@ -106,52 +106,46 @@ fn main() { .run(); } -fn setup(mut commands: Commands) { - commands.spawn(( - Camera3d::default(), - Hdr, - Transform::from_xyz(15.0, 10.0, 20.0).looking_at(Vec3::ZERO, Vec3::Y), - FreeCamera::default(), +fn scene() -> impl SceneList { + bsn_list![camera(), sun(), loading_screen()] +} + +fn camera() -> impl Scene { + bsn! { + Camera3d + Hdr + template_value(Transform::from_xyz(15.0, 10.0, 20.0).looking_at(Vec3::ZERO, Vec3::Y)) + FreeCamera AtmosphereSettings { // Reduce the default max distance in the aerial view LUT // to 16km to approximately fit the size of the city. This way the aerial perspective // gets more detail and has less banding artifacts compared to the 32km default. aerial_view_lut_max_distance: 1.6e4, - ..default() - }, + } // The directional light illuminance used in this scene is // quite bright, so raising the exposure compensation helps // bring the scene to a nicer brightness range. - Exposure::OVERCAST, + Exposure::OVERCAST // Bloom gives the sun a much more natural look. - Bloom::NATURAL, + Bloom::NATURAL // Enables the atmosphere to drive reflections and ambient lighting (IBL) for this view - AtmosphereEnvironmentMapLight::default(), - Msaa::Off, - TemporalAntiAliasing::default(), - ContactShadows::default(), - )); - - commands.spawn(( - DirectionalLight { - shadow_maps_enabled: Settings::default().shadow_maps_enabled, - contact_shadows_enabled: Settings::default().contact_shadows_enabled, - illuminance: light_consts::lux::RAW_SUNLIGHT, - ..default() - }, - Transform::from_xyz(1.0, 0.15, 1.0).looking_at(Vec3::ZERO, Vec3::Y), - )); + AtmosphereEnvironmentMapLight + Msaa::Off + TemporalAntiAliasing + ContactShadows + } +} - commands.spawn(( - LoadingScreen, +fn loading_screen() -> impl Scene { + bsn! { + LoadingScreen Node { position_type: PositionType::Absolute, width: Val::Percent(100.0), height: Val::Percent(100.0), - ..default() - }, - BackgroundColor(Color::BLACK), - children![( + } + BackgroundColor(Color::BLACK) + Children [ Node { position_type: PositionType::Absolute, top: Val::Percent(50.0), @@ -161,28 +155,36 @@ fn setup(mut commands: Commands) { flex_direction: FlexDirection::Column, align_items: AlignItems::FlexStart, overflow: Overflow::scroll_y(), - ..default() - }, - children![ + } + Children [ ( - LoadingText, - Text::new("Loading..."), + LoadingText + Text("Loading...") TextFont { font_size: FontSize::Px(24.0), - ..default() - }, + } ), ( - LoadingPaths, - Text::new(""), + LoadingPaths + Text TextFont { font_size: FontSize::Px(14.0), - ..default() - }, + } ), ] - )], - )); + ] + } +} + +fn sun() -> impl Scene { + bsn! { + DirectionalLight { + shadow_maps_enabled: {Settings::default().shadow_maps_enabled}, + contact_shadows_enabled: {Settings::default().contact_shadows_enabled}, + illuminance: light_consts::lux::RAW_SUNLIGHT, + } + template_value(Transform::from_xyz(1.0, 0.15, 1.0).looking_at(Vec3::ZERO, Vec3::Y)) + } } /// Spawns the earth atmosphere plus an extra near-ground fog term. @@ -229,11 +231,11 @@ fn spawn_atmosphere( )); } -#[derive(Component)] +#[derive(Component, Default, Clone)] struct LoadingScreen; -#[derive(Component)] +#[derive(Component, Default, Clone)] struct LoadingText; -#[derive(Component)] +#[derive(Component, Default, Clone)] struct LoadingPaths; /// Triggers when all the assets managed in [`CityAssets`] are loaded @@ -247,7 +249,7 @@ struct CityAssetsReady; struct CitySpawned; #[allow(clippy::type_complexity)] -fn loading_screen( +fn update_loading_screen( mut commands: Commands, assets: Res, asset_server: Res, diff --git a/examples/large_scenes/bevy_city/src/settings.rs b/examples/large_scenes/bevy_city/src/settings.rs index 1c4e0988562ad..54d9f1f99426e 100644 --- a/examples/large_scenes/bevy_city/src/settings.rs +++ b/examples/large_scenes/bevy_city/src/settings.rs @@ -3,7 +3,7 @@ use bevy::{ camera_controller::free_camera::FreeCameraState, feathers::{ self, - controls::{button, checkbox, ButtonProps, CheckboxProps}, + controls::{FeathersButton, FeathersCheckbox}, theme::{ThemeBackgroundColor, ThemedText}, }, pbr::wireframe::WireframeConfig, @@ -37,10 +37,6 @@ impl Default for Settings { } } -pub fn setup_settings_ui(mut commands: Commands) { - commands.spawn_scene(settings_ui()); -} - pub fn settings_ui() -> impl Scene { bsn! { Node { @@ -67,11 +63,9 @@ pub fn settings_ui() -> impl Scene { Children [ Text("Settings"), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("Simulate Cars") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn! { Text("Simulate Cars") ThemedText }} + } Checked on(checkbox_self_update) on(|change: On>, mut settings: ResMut| { @@ -79,11 +73,9 @@ pub fn settings_ui() -> impl Scene { }) ), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("Shadow maps enabled") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn!{ Text("Shadow maps enabled") ThemedText }} + } Checked on(checkbox_self_update) on( @@ -99,11 +91,9 @@ pub fn settings_ui() -> impl Scene { ) ), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("Contact shadows enabled") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn! { Text("Contact shadows enabled") ThemedText }} + } Checked on(checkbox_self_update) on( @@ -119,11 +109,9 @@ pub fn settings_ui() -> impl Scene { ) ), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("Wireframe Enabled") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn!{ Text("Wireframe Enabled") ThemedText }} + } on(checkbox_self_update) on( |change: On>, @@ -135,11 +123,9 @@ pub fn settings_ui() -> impl Scene { ) ), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("CPU culling") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn!{ Text("CPU culling") ThemedText }} + } Checked on(checkbox_self_update) on( @@ -160,12 +146,9 @@ pub fn settings_ui() -> impl Scene { ) ), ( - button(ButtonProps { - caption: Box::new(bsn_list!( - (Text("Regenerate City") ThemedText), - )), - ..Default::default() - }) + :FeathersButton { + @caption: {bsn!{ Text("Regenerate City") ThemedText }} + } on( |_activate: On, mut commands: Commands, diff --git a/examples/ui/widgets/feathers_counter.rs b/examples/ui/widgets/feathers_counter.rs index 642f70d84276c..eceba40be7ec5 100644 --- a/examples/ui/widgets/feathers_counter.rs +++ b/examples/ui/widgets/feathers_counter.rs @@ -4,7 +4,7 @@ use bevy::{ feathers::{ - controls::{button, ButtonProps}, + controls::FeathersButton, dark_theme::create_dark_theme, theme::{ThemeBackgroundColor, ThemedText, UiTheme}, tokens, FeathersPlugins, @@ -58,24 +58,24 @@ fn demo_root() -> impl Scene { } Children [ ( - button(ButtonProps::default()) + :FeathersButton on(|_activate: On, mut counter: ResMut| { counter.0 -= 1; }) - Children [ (Text::new("-1") ThemedText) ] + Children [ (Text("-1") ThemedText) ] ), ( Node { margin: UiRect::horizontal(px(10.0)), } - Text::new("0") ThemedText CounterText + Text("0") ThemedText CounterText ), ( - button(ButtonProps::default()) + :FeathersButton on(|_activate: On, mut counter: ResMut| { counter.0 += 1; }) - Children [ (Text::new("+1") ThemedText) ] + Children [ (Text("+1") ThemedText) ] ) ] )] diff --git a/examples/ui/widgets/feathers_gallery.rs b/examples/ui/widgets/feathers_gallery.rs index 3f6272b58baf8..c6b71bca32a22 100644 --- a/examples/ui/widgets/feathers_gallery.rs +++ b/examples/ui/widgets/feathers_gallery.rs @@ -2,6 +2,7 @@ use bevy::{ color::palettes, + ecs::VariantDefaults, feathers::{ constants::{fonts, icons}, containers::{ @@ -9,13 +10,12 @@ use bevy::{ pane_header_divider, subpane, subpane_body, subpane_header, }, controls::{ - button, checkbox, color_plane, color_slider, color_swatch, disclosure_toggle, menu, - menu_button, menu_divider, menu_item, menu_popup, number_input, radio, slider, - text_input, text_input_container, toggle_switch, tool_button, ButtonProps, - ButtonVariant, CheckboxProps, ColorChannel, ColorPlane, ColorPlaneValue, ColorSlider, - ColorSliderProps, ColorSwatch, ColorSwatchValue, MenuButtonProps, MenuItemProps, - NumberInputProps, NumberInputValue, RadioProps, SliderBaseColor, SliderProps, - TextInputProps, UpdateNumberInput, + ButtonVariant, ColorChannel, ColorPlaneValue, ColorSlider, ColorSwatchValue, + FeathersButton, FeathersCheckbox, FeathersColorPlane, FeathersColorSlider, + FeathersColorSwatch, FeathersDisclosureToggle, FeathersMenu, FeathersMenuButton, + FeathersMenuDivider, FeathersMenuItem, FeathersMenuPopup, FeathersNumberInput, + FeathersRadio, FeathersSlider, FeathersTextInput, FeathersTextInputContainer, + FeathersToggleSwitch, NumberInputValue, SliderBaseColor, ToolButton, UpdateNumberInput, }, cursor::{EntityCursor, OverrideCursor}, dark_theme::create_dark_theme, @@ -62,7 +62,7 @@ struct DemoDisabledButton; #[derive(Component, Clone, Copy, Default)] struct DemoScalarField; -#[derive(Component, Clone, Copy, Default)] +#[derive(Component, Clone, Copy, Default, VariantDefaults)] enum DemoVec3Field { #[default] X, @@ -132,12 +132,9 @@ fn demo_column_1() -> impl Scene { } Children [ ( - button(ButtonProps { - caption: Box::new(bsn_list!( - (Text("Normal") ThemedText), - )), - ..default() - }) + :FeathersButton { + @caption: {bsn!{ Text("Normal") ThemedText }} + } Node { flex_grow: 1.0, } @@ -147,12 +144,9 @@ fn demo_column_1() -> impl Scene { AutoFocus ), ( - button(ButtonProps { - caption: Box::new(bsn_list!( - (Text("Disabled") ThemedText), - )), - ..default() - }) + :FeathersButton { + @caption: {bsn!{ Text("Disabled") ThemedText }}, + } Node { flex_grow: 1.0, } @@ -163,13 +157,10 @@ fn demo_column_1() -> impl Scene { }) ), ( - button(ButtonProps { - caption: Box::new(bsn_list!( - (Text("Primary") ThemedText), - )), - variant: ButtonVariant::Primary, - ..default() - }) + :FeathersButton { + @caption: {bsn!{ Text("Primary") ThemedText }}, + @variant: ButtonVariant::Primary, + } Node { flex_grow: 1.0, } @@ -178,46 +169,40 @@ fn demo_column_1() -> impl Scene { }) ), ( - :menu + :FeathersMenu Children [ ( - :menu_button(MenuButtonProps { - caption: Box::new(bsn_list!( - (Text("Menu") ThemedText), - )), - ..default() - }) + :FeathersMenuButton { + @caption: {bsn! { Text("Menu") ThemedText }} + } Node { flex_grow: 1.0, } ), ( - :menu_popup + :FeathersMenuPopup Children [ ( - menu_item(MenuItemProps { - caption: Box::new(bsn_list!( - (Text("MenuItem 1") ThemedText))) - }) + :FeathersMenuItem { + @caption: {bsn!{ Text("MenuItem 1") ThemedText }} + } on(|_: On| { info!("Menu item 1 clicked!"); }) ), ( - menu_item(MenuItemProps { - caption: Box::new(bsn_list!( - (Text("MenuItem 2") ThemedText))) - }) + :FeathersMenuItem { + @caption: {bsn!{ Text("MenuItem 2") ThemedText }} + } on(|_: On| { info!("Menu item 2 clicked!"); }) ), - :menu_divider, + :FeathersMenuDivider, ( - menu_item(MenuItemProps { - caption: Box::new(bsn_list!( - (Text("MenuItem 3") ThemedText))) - }) + :FeathersMenuItem { + @caption: {bsn!{ Text("MenuItem 3") ThemedText }} + } on(|_: On| { info!("Menu item 3 clicked!"); }) @@ -238,13 +223,10 @@ fn demo_column_1() -> impl Scene { } Children [ ( - button(ButtonProps { - caption: Box::new(bsn_list!( - (Text("Left") ThemedText), - )), - corners: RoundedCorners::Left, - ..default() - }) + :FeathersButton { + @caption: {bsn!{ Text("Left") ThemedText }}, + @corners: RoundedCorners::Left, + } Node { flex_grow: 1.0, } @@ -253,13 +235,10 @@ fn demo_column_1() -> impl Scene { }) ), ( - button(ButtonProps { - caption: Box::new(bsn_list!( - (Text("Center") ThemedText), - )), - corners: RoundedCorners::None, - ..default() - }) + :FeathersButton { + @caption: {bsn!{ Text("Center") ThemedText }}, + @corners: RoundedCorners::None, + } Node { flex_grow: 1.0, } @@ -268,13 +247,11 @@ fn demo_column_1() -> impl Scene { }) ), ( - button(ButtonProps { - caption: Box::new(bsn_list!( - (Text("Right") ThemedText), - )), - variant: ButtonVariant::Primary, - corners: RoundedCorners::Right, - }) + :FeathersButton { + @caption: {bsn!{ Text("Right") ThemedText }}, + @variant: ButtonVariant::Primary, + @corners: RoundedCorners::Right, + } Node { flex_grow: 1.0, } @@ -285,7 +262,7 @@ fn demo_column_1() -> impl Scene { ] ), ( - button(ButtonProps::default()) + :FeathersButton on(|_activate: On, mut ovr: ResMut| { ovr.0 = if ovr.0.is_some() { None @@ -294,14 +271,12 @@ fn demo_column_1() -> impl Scene { }; info!("Override cursor button clicked!"); }) - Children [ (Text::new("Toggle override") ThemedText) ] + Children [ (Text("Toggle override") ThemedText) ] ), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("Checkbox") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn!{ Text("Checkbox") ThemedText }} + } Checked on( |change: On>, @@ -324,11 +299,9 @@ fn demo_column_1() -> impl Scene { ) ), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("Fast Click Checkbox") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn!{ Text("Fast Click Checkbox") ThemedText }} + } ActivateOnPress on( |change: On>, @@ -344,22 +317,18 @@ fn demo_column_1() -> impl Scene { ) ), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("Disabled") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn!{ Text("Disabled") ThemedText }}, + } InteractionDisabled on(|_change: On>| { warn!("Disabled checkbox clicked!"); }) ), ( - checkbox(CheckboxProps { - caption: Box::new(bsn_list!( - (Text("Checked+Disabled") ThemedText), - )), - }) + :FeathersCheckbox { + @caption: {bsn!{ Text("Checked+Disabled") ThemedText }} + } InteractionDisabled Checked on(|_change: On>| { @@ -384,57 +353,27 @@ fn demo_column_1() -> impl Scene { RadioGroup on(radio_self_update) Children [ - (radio(RadioProps { - caption: Box::new(bsn_list!( - (Text("One") ThemedText), - )), - }) Checked), - (radio(RadioProps { - caption: Box::new(bsn_list!( - (Text("Two") ThemedText), - )), - })), - (radio(RadioProps { - caption: Box::new(bsn_list!( - (Text("Fast Click") ThemedText), - )), - }) ActivateOnPress), - (radio(RadioProps { - caption: Box::new(bsn_list!( - (Text("Disabled") ThemedText), - )), - }) InteractionDisabled), - ] - ), - ( - Node { - display: Display::Flex, - flex_direction: FlexDirection::Column, - row_gap: px(4), - } - RadioGroup - on(radio_self_update) - Children [ - (radio(RadioProps { - caption: Box::new(bsn_list!( - (Text("One") ThemedText), - )), - }) Checked), - (radio(RadioProps { - caption: Box::new(bsn_list!( - (Text("Two") ThemedText), - )), - })), - (radio(RadioProps { - caption: Box::new(bsn_list!( - (Text("Fast Click") ThemedText), - )), - }) ActivateOnPress), - (radio(RadioProps { - caption: Box::new(bsn_list!( - (Text("Disabled") ThemedText), - )), - }) InteractionDisabled), + ( + :FeathersRadio { + @caption: {bsn!{ Text("One") ThemedText }} + } + Checked + ), + :FeathersRadio { + @caption: {bsn!{ Text("Two") ThemedText }} + }, + ( + :FeathersRadio { + @caption: {bsn!{ Text("Fast Click") ThemedText }} + } + ActivateOnPress + ), + ( + :FeathersRadio { + @caption: {bsn!{ Text("Disabled") ThemedText }} + } + InteractionDisabled + ), ] ) ] @@ -448,19 +387,18 @@ fn demo_column_1() -> impl Scene { column_gap: px(8), } Children [ - (toggle_switch() on(checkbox_self_update)), - (toggle_switch() ActivateOnPress on(checkbox_self_update)), - (toggle_switch() InteractionDisabled on(checkbox_self_update)), - (toggle_switch() InteractionDisabled Checked on(checkbox_self_update)), - (disclosure_toggle() on(checkbox_self_update)), + (:FeathersToggleSwitch on(checkbox_self_update)), + (:FeathersToggleSwitch ActivateOnPress on(checkbox_self_update)), + (:FeathersToggleSwitch InteractionDisabled on(checkbox_self_update)), + (:FeathersToggleSwitch InteractionDisabled Checked on(checkbox_self_update)), + (:FeathersDisclosureToggle on(checkbox_self_update)), ] ), ( - slider(SliderProps { - max: 100.0, - value: 20.0, - ..default() - }) + :FeathersSlider { + @max: 100.0, + @value: 20.0, + } SliderStep(10.) SliderPrecision(2) on(slider_self_update) @@ -479,17 +417,17 @@ fn demo_column_1() -> impl Scene { :flex_spacer, // Text input ( - :text_input_container() + :FeathersTextInputContainer Node { flex_grow: 0. padding: { px(4.).left().with_right(px(0.)) }, } Children [ ( - text_input(TextInputProps { - visible_width: Some(10.), - max_characters: Some(9), - }) + :FeathersTextInput { + @visible_width: 10f32, + @max_characters: 9usize, + } InheritableFont { font: fonts::MONO } @@ -498,48 +436,48 @@ fn demo_column_1() -> impl Scene { ) ] ) - (color_swatch() SwatchType::Rgb), + (:FeathersColorSwatch SwatchType::Rgb), ] ), ( - color_plane(ColorPlane::RedBlue) + :FeathersColorPlane::RedBlue on(|change: On>, mut color: ResMut| { color.rgb_color.red = change.value.x; color.rgb_color.blue = change.value.y; }) ), ( - color_slider(ColorSliderProps { - value: 0.5, - channel: ColorChannel::Red - }) + :FeathersColorSlider { + @value: 0.5, + @channel: ColorChannel::Red + } on(|change: On>, mut color: ResMut| { color.rgb_color.red = change.value; }) ), ( - color_slider(ColorSliderProps { - value: 0.5, - channel: ColorChannel::Green - }) + :FeathersColorSlider { + @value: 0.5, + @channel: ColorChannel::Green + } on(|change: On>, mut color: ResMut| { color.rgb_color.green = change.value; }) ), ( - color_slider(ColorSliderProps { - value: 0.5, - channel: ColorChannel::Blue - }) + :FeathersColorSlider { + @value: 0.5, + @channel: ColorChannel::Blue + } on(|change: On>, mut color: ResMut| { color.rgb_color.blue = change.value; }) ), ( - color_slider(ColorSliderProps { - value: 0.5, - channel: ColorChannel::Alpha - }) + :FeathersColorSlider { + @value: 0.5, + @channel: ColorChannel::Alpha + } on(|change: On>, mut color: ResMut| { color.rgb_color.alpha = change.value; }) @@ -553,32 +491,32 @@ fn demo_column_1() -> impl Scene { } Children [ :label("Hsl"), - (color_swatch() SwatchType::Hsl) + (:FeathersColorSwatch SwatchType::Hsl) ] ), ( - color_slider(ColorSliderProps { - value: 0.5, - channel: ColorChannel::HslHue - }) + :FeathersColorSlider { + @value: 0.5, + @channel: ColorChannel::HslHue + } on(|change: On>, mut color: ResMut| { color.hsl_color.hue = change.value; }) ), ( - color_slider(ColorSliderProps { - value: 0.5, - channel: ColorChannel::HslSaturation - }) + :FeathersColorSlider { + @value: 0.5, + @channel: ColorChannel::HslSaturation + } on(|change: On>, mut color: ResMut| { color.hsl_color.saturation = change.value; }) ), ( - color_slider(ColorSliderProps { - value: 0.5, - channel: ColorChannel::HslLightness - }) + :FeathersColorSlider { + @value: 0.5, + @channel: ColorChannel::HslLightness + } on(|change: On>, mut color: ResMut| { color.hsl_color.lightness = change.value; }) @@ -603,43 +541,37 @@ fn demo_column_2() -> impl Scene { ( :pane Children [ :pane_header Children [ - :tool_button(ButtonProps { - variant: ButtonVariant::Primary, - ..default() - }) Children [ + :ToolButton { + @variant: ButtonVariant::Primary, + } Children [ (Text("\u{0398}") ThemedText) ], :pane_header_divider, - :tool_button(ButtonProps{ - variant: ButtonVariant::Plain, - ..default() - }) Children [ + :ToolButton { + @variant: ButtonVariant::Plain, + } Children [ (Text("\u{00BC}") ThemedText) ], - :tool_button(ButtonProps{ - variant: ButtonVariant::Plain, - ..default() - }) Children [ + :ToolButton { + @variant: ButtonVariant::Plain, + } Children [ (Text("\u{00BD}") ThemedText) ], - :tool_button(ButtonProps{ - variant: ButtonVariant::Plain, - ..default() - }) Children [ + :ToolButton { + @variant: ButtonVariant::Plain, + } Children [ (Text("\u{00BE}") ThemedText) ], :pane_header_divider, - :tool_button(ButtonProps{ - variant: ButtonVariant::Plain, - ..default() - }) Children [ + :ToolButton { + @variant: ButtonVariant::Plain, + } Children [ :icon(icons::CHEVRON_DOWN) ], :flex_spacer, - :tool_button(ButtonProps{ - variant: ButtonVariant::Plain, - ..default() - }) Children [ + :ToolButton { + @variant: ButtonVariant::Plain, + } Children [ :icon(icons::X) ], ], @@ -664,7 +596,7 @@ fn demo_column_2() -> impl Scene { :label("A standard group"), :label_small("Scalar property"), ( - :number_input(NumberInputProps::default()) + :FeathersNumberInput DemoScalarField Node { flex_grow: 1.0, @@ -680,7 +612,7 @@ fn demo_column_2() -> impl Scene { ), :label_small("Scalar property (copy)"), ( - :number_input(NumberInputProps::default()) + :FeathersNumberInput DemoScalarField Node { flex_grow: 1.0, @@ -704,12 +636,11 @@ fn demo_column_2() -> impl Scene { } Children [ ( - :number_input(NumberInputProps { - sigil_color: tokens::TEXT_INPUT_X_AXIS, - label_text: Some("X"), - ..default() - }) - template_value(DemoVec3Field::X) + :FeathersNumberInput { + @sigil_color: tokens::TEXT_INPUT_X_AXIS, + @label_text: "X", + } + DemoVec3Field::X Node { flex_grow: 1.0, } @@ -723,12 +654,11 @@ fn demo_column_2() -> impl Scene { }) ), ( - :number_input(NumberInputProps { - sigil_color: tokens::TEXT_INPUT_Y_AXIS, - label_text: Some("Y"), - ..default() - }) - template_value(DemoVec3Field::Y) + :FeathersNumberInput { + @sigil_color: tokens::TEXT_INPUT_Y_AXIS, + @label_text: "Y", + } + DemoVec3Field::Y Node { flex_grow: 1.0, } @@ -741,12 +671,11 @@ fn demo_column_2() -> impl Scene { }) ), ( - :number_input(NumberInputProps { - sigil_color: tokens::TEXT_INPUT_Z_AXIS, - label_text: Some("Z"), - ..default() - }) - template_value(DemoVec3Field::Z) + :FeathersNumberInput { + @sigil_color: tokens::TEXT_INPUT_Z_AXIS, + @label_text: "Z", + } + DemoVec3Field::Z Node { flex_grow: 1.0, } @@ -774,8 +703,8 @@ fn demo_column_2() -> impl Scene { fn update_colors( states: Res, mut sliders: Query<(Entity, &ColorSlider, &mut SliderBaseColor)>, - mut swatches: Query<(&mut ColorSwatchValue, &SwatchType), With>, - mut color_planes: Query<&mut ColorPlaneValue, With>, + mut swatches: Query<(&mut ColorSwatchValue, &SwatchType), With>, + mut color_planes: Query<&mut ColorPlaneValue, With>, q_text_input: Single<(Entity, &mut EditableText), With>, q_scalar_input: Query>, q_vec3_input: Query<(Entity, &DemoVec3Field)>, diff --git a/examples/ui/widgets/virtual_keyboard.rs b/examples/ui/widgets/virtual_keyboard.rs index 70cde4c057383..ebd07d2ab0103 100644 --- a/examples/ui/widgets/virtual_keyboard.rs +++ b/examples/ui/widgets/virtual_keyboard.rs @@ -3,7 +3,7 @@ use bevy::{ color::palettes::css::NAVY, feathers::{ - controls::{virtual_keyboard, VirtualKeyPressed}, + controls::{VirtualKeyPressed, VirtualKeyboard}, dark_theme::create_dark_theme, theme::UiTheme, FeathersPlugins, @@ -28,7 +28,7 @@ fn scene() -> impl SceneList { } fn keyboard() -> impl Scene { - let layout = [ + let keys = [ vec!["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", ".", ","], vec!["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"], vec!["A", "S", "D", "F", "G", "H", "J", "K", "L", "'"], @@ -59,7 +59,7 @@ fn keyboard() -> impl Scene { Children [ Text("virtual keyboard"), ( - virtual_keyboard(layout.into_iter()) + :VirtualKeyboard::<&str> { @keys: keys } on(on_virtual_key_pressed) ) ] From e04d9fb0483aaf0a079e46f7b170eeef0aa503c3 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Mon, 27 Apr 2026 18:15:34 -0700 Subject: [PATCH 02/15] Fix docs --- crates/bevy_feathers/src/controls/menu.rs | 25 +++++++++++-------- .../src/controls/number_input.rs | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/crates/bevy_feathers/src/controls/menu.rs b/crates/bevy_feathers/src/controls/menu.rs index 5796049401435..b1aa833a3d585 100644 --- a/crates/bevy_feathers/src/controls/menu.rs +++ b/crates/bevy_feathers/src/controls/menu.rs @@ -132,13 +132,14 @@ fn on_menu_event( } /// A menu button widget. This produces a button that has a dropdown arrow. -/// This can be spawned as a "scene component" with [`MenuButtonProps`]. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersMenuButtonProps`]. #[derive(Component, Default, Clone)] -#[component(scene_props = MenuButtonProps)] +#[component(scene_props = FeathersMenuButtonProps)] pub struct FeathersMenuButton; -/// Parameters for the menu button template, passed to [`menu_button`] function. -pub struct MenuButtonProps { +/// Props used to construct a [`FeathersMenuButton`] scene. +pub struct FeathersMenuButtonProps { /// Label for this menu button pub caption: Box, /// Rounded corners options @@ -147,7 +148,7 @@ pub struct MenuButtonProps { pub arrow: bool, } -impl Default for MenuButtonProps { +impl Default for FeathersMenuButtonProps { fn default() -> Self { Self { caption: Box::new(bsn_list!()), @@ -157,7 +158,7 @@ impl Default for MenuButtonProps { } } impl FeathersMenuButton { - fn scene(props: MenuButtonProps) -> impl Scene { + fn scene(props: FeathersMenuButtonProps) -> impl Scene { bsn! { :FeathersButton { @caption: {props.caption}, @@ -240,17 +241,19 @@ impl FeathersMenuPopup { } /// A menu item widget. +/// +/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersMenuItemProps`]. #[derive(Component, Default, Clone)] -#[component(scene_props = MenuItemProps )] +#[component(scene_props = FeathersMenuItemProps )] pub struct FeathersMenuItem; -/// Parameters for the menu button template, passed to [`menu_button`] function. -pub struct MenuItemProps { +/// Props used to construct a [`FeathersMenuItem`] scene. +pub struct FeathersMenuItemProps { /// Label for this menu item pub caption: Box, } -impl Default for MenuItemProps { +impl Default for FeathersMenuItemProps { fn default() -> Self { Self { caption: Box::new(bsn_list!()), @@ -259,7 +262,7 @@ impl Default for MenuItemProps { } impl FeathersMenuItem { - fn scene(props: MenuItemProps) -> impl Scene { + fn scene(props: FeathersMenuItemProps) -> impl Scene { bsn! { Node { height: size::ROW_HEIGHT, diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 276c6c44c7ccb..9806967a3d022 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -42,7 +42,7 @@ use crate::{ /// /// In most cases, the actual source of truth for the numeric value will be external, that is, /// some property in an app-specific data structure. It's the responsibility of the app to -/// synchronize this value with the [`number_input`] widget in both directions: +/// synchronize this value with the [`FeathersNumberInput`] widget in both directions: /// * When a [`ValueChange`] event is received, update the app-specific property. /// * When the app-specific property changes - either in response to a [`ValueChange`] event, or /// because of some other action, trigger an [`UpdateNumberInput`] entity event to update the From b55393aec6943e881b8793e433dc2e8f5d24dfe4 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Mon, 27 Apr 2026 18:59:53 -0700 Subject: [PATCH 03/15] Detect prop syntax in normal component patches and provide helpful error message. --- crates/bevy_scene/macros/src/bsn/codegen.rs | 57 +++++++++++++++------ 1 file changed, 41 insertions(+), 16 deletions(-) diff --git a/crates/bevy_scene/macros/src/bsn/codegen.rs b/crates/bevy_scene/macros/src/bsn/codegen.rs index e224bd0f52237..0b83d92537550 100644 --- a/crates/bevy_scene/macros/src/bsn/codegen.rs +++ b/crates/bevy_scene/macros/src/bsn/codegen.rs @@ -146,7 +146,11 @@ impl Bsn { } } -fn from_template_patch(ctx: &mut BsnCodegenCtx, ty: &BsnType) -> syn::Result { +fn from_template_patch( + ctx: &mut BsnCodegenCtx, + ty: &BsnType, + is_scene_component: bool, +) -> syn::Result { let mut assigns = Vec::new(); let target = PatchTarget { path: &[Member::Named(Ident::new( @@ -155,7 +159,7 @@ fn from_template_patch(ctx: &mut BsnCodegenCtx, ty: &BsnType) -> syn::Result from_template_patch(ctx, ty), + BsnEntry::FromTemplatePatch(ty) => from_template_patch(ctx, ty, false), BsnEntry::TemplateConst { type_path, const_ident, @@ -244,14 +248,15 @@ impl BsnEntry { ctx, &mut assignments, false, + true, + true, PatchTarget { path: &[Member::Named(props_ref.clone())], is_ref: true, }, - true, )?; let type_path = &bsn_type.path; - let from_template_patch = from_template_patch(ctx, bsn_type)?; + let from_template_patch = from_template_patch(ctx, bsn_type, true)?; quote! {{ let mut #props = <<#type_path as #bevy_scene::SceneConstructor>::Props as Default>::default(); let #props_ref = &mut #props; @@ -285,8 +290,9 @@ impl BsnType { ctx: &mut BsnCodegenCtx, assignments: &mut Vec, is_root: bool, - target: PatchTarget, is_props: bool, + is_scene_component: bool, + target: PatchTarget, ) -> syn::Result<()> { if !is_root { let (path, bevy_scene) = (&self.path, ctx.bevy_scene); @@ -295,12 +301,12 @@ impl BsnType { if let Some(variant) = &self.enum_variant { if is_props { - self.push_struct_patch(ctx, assignments, target, true)?; + self.push_struct_patch(ctx, assignments, true, is_scene_component, target)?; } else { self.push_enum_patch(ctx, variant, assignments, target)?; } } else { - self.push_struct_patch(ctx, assignments, target, is_props)?; + self.push_struct_patch(ctx, assignments, is_props, is_scene_component, target)?; } Ok(()) @@ -385,18 +391,32 @@ impl BsnType { &self, ctx: &mut BsnCodegenCtx, assignments: &mut Vec, - target: PatchTarget, is_props: bool, + is_scene_component: bool, + target: PatchTarget, ) -> syn::Result<()> { match &self.fields { BsnFields::Named(fields) => { let mut seen = HashSet::with_capacity(fields.len()); for field in fields { + let field_name = &field.name; if is_props != field.is_prop { + if !is_scene_component && field.is_prop { + let type_path = &self.path; + ctx.errors.push(syn::Error::new_spanned( + field_name, + format!( + "Scene prop fields are not supported in normal component patches\ + . If you would like to set a component scene's prop field, it \ + should be set using \"scene inheritance\": \ + bsn! {{ :{} {{ @{field_name}: VALUE }} }}", + type_path.to_token_stream().to_string() + ), + )); + } continue; } - let field_name = &field.name; if !seen.insert(field_name.to_string()) { ctx.errors.push(syn::Error::new_spanned( field_name, @@ -497,11 +517,12 @@ impl BsnType { ctx, assignments, false, + false, + false, PatchTarget { path: &new_path, is_ref: false, }, - false, )?; } } @@ -529,11 +550,12 @@ impl BsnType { ctx, &mut type_assigns, false, + false, + false, PatchTarget { path: &[Member::Named(bind_name.clone())], is_ref: true, }, - false, )?; return Ok(quote! {#(#type_assigns)*}); } @@ -672,11 +694,12 @@ mod tests { let res = duplicate.push_struct_patch( &mut ctx, &mut assignments, + false, + false, PatchTarget { path: &[], is_ref: false, }, - false, ); assert!(res.is_ok()); @@ -722,11 +745,12 @@ mod tests { &mut ctx, &mut assignments, true, + false, + false, PatchTarget { path: &[], is_ref: false, }, - false, ); assert!(res.is_ok()); @@ -756,11 +780,12 @@ mod tests { let res = missing.push_struct_patch( &mut ctx, &mut assignments, + false, + false, PatchTarget { path: &[Member::Named(parse_quote!(value))], is_ref: false, }, - false, ); assert!(res.is_ok()); From e2a3505dd0b76afc57053b05ea966c29eb4f5c0a Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Mon, 27 Apr 2026 19:15:36 -0700 Subject: [PATCH 04/15] Remove unnecessary to_string() --- crates/bevy_scene/macros/src/bsn/codegen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_scene/macros/src/bsn/codegen.rs b/crates/bevy_scene/macros/src/bsn/codegen.rs index 0b83d92537550..3461039d98842 100644 --- a/crates/bevy_scene/macros/src/bsn/codegen.rs +++ b/crates/bevy_scene/macros/src/bsn/codegen.rs @@ -411,7 +411,7 @@ impl BsnType { . If you would like to set a component scene's prop field, it \ should be set using \"scene inheritance\": \ bsn! {{ :{} {{ @{field_name}: VALUE }} }}", - type_path.to_token_stream().to_string() + type_path.to_token_stream() ), )); } From cbc762b7d715672088eff7a54aca2350cc7168c4 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Wed, 29 Apr 2026 13:40:21 -0700 Subject: [PATCH 05/15] path_to_string --- crates/bevy_macro_utils/src/lib.rs | 2 ++ crates/bevy_macro_utils/src/path.rs | 15 +++++++++++++++ crates/bevy_scene/macros/src/bsn/codegen.rs | 3 ++- crates/bevy_scene/macros/src/bsn/parse.rs | 14 +++++++++++--- 4 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 crates/bevy_macro_utils/src/path.rs diff --git a/crates/bevy_macro_utils/src/lib.rs b/crates/bevy_macro_utils/src/lib.rs index cffed77dd443e..b4dcb9c3b4541 100644 --- a/crates/bevy_macro_utils/src/lib.rs +++ b/crates/bevy_macro_utils/src/lib.rs @@ -16,6 +16,7 @@ pub mod fq_std; mod label; mod member; mod parser; +mod path; mod result_sifter; mod shape; mod symbol; @@ -25,6 +26,7 @@ pub use bevy_manifest::*; pub use label::*; pub use member::*; pub use parser::*; +pub use path::*; pub use result_sifter::*; pub use shape::*; pub use symbol::*; diff --git a/crates/bevy_macro_utils/src/path.rs b/crates/bevy_macro_utils/src/path.rs new file mode 100644 index 0000000000000..96a848379ee98 --- /dev/null +++ b/crates/bevy_macro_utils/src/path.rs @@ -0,0 +1,15 @@ +use syn::Path; + +/// This formats the given `path` in standard Rust format. +/// +/// ## Why does this exist? +/// +/// [`Path`] does not include a `to_string()` function or a [`Debug`] impl. Hacks like +/// `path.to_token_stream().to_string()` exist, but they produce ugly spaces between the `::` separators. +pub fn path_to_string(path: &Path) -> String { + path.segments + .iter() + .map(|seg| seg.ident.to_string()) + .collect::>() + .join("::") +} diff --git a/crates/bevy_scene/macros/src/bsn/codegen.rs b/crates/bevy_scene/macros/src/bsn/codegen.rs index 3461039d98842..fc51d09f7e2b0 100644 --- a/crates/bevy_scene/macros/src/bsn/codegen.rs +++ b/crates/bevy_scene/macros/src/bsn/codegen.rs @@ -2,6 +2,7 @@ use crate::bsn::types::{ Bsn, BsnConstructor, BsnEntry, BsnFields, BsnInheritedScene, BsnListRoot, BsnRelatedSceneList, BsnRoot, BsnSceneListItem, BsnSceneListItems, BsnType, BsnValue, }; +use bevy_macro_utils::path_to_string; use proc_macro2::TokenStream; use quote::{format_ident, quote, ToTokens}; use std::collections::{hash_map::Entry, HashMap, HashSet}; @@ -411,7 +412,7 @@ impl BsnType { . If you would like to set a component scene's prop field, it \ should be set using \"scene inheritance\": \ bsn! {{ :{} {{ @{field_name}: VALUE }} }}", - type_path.to_token_stream() + path_to_string(type_path) ), )); } diff --git a/crates/bevy_scene/macros/src/bsn/parse.rs b/crates/bevy_scene/macros/src/bsn/parse.rs index 21e4ad1cd11b0..d1995dc56447c 100644 --- a/crates/bevy_scene/macros/src/bsn/parse.rs +++ b/crates/bevy_scene/macros/src/bsn/parse.rs @@ -3,6 +3,7 @@ use crate::bsn::types::{ BsnRelatedSceneList, BsnRoot, BsnSceneList, BsnSceneListItem, BsnSceneListItems, BsnTuple, BsnType, BsnUnnamedField, BsnValue, }; +use bevy_macro_utils::path_to_string; use proc_macro2::{Delimiter, TokenStream, TokenTree}; use quote::quote; use syn::{ @@ -232,7 +233,7 @@ impl BsnInheritedScene { PathType::Type | PathType::Enum => { BsnInheritedScene::Type(input.parse::()?) } - PathType::Function => { + PathType::Function | PathType::TypeFunction => { let path = input.parse::()?; let args = if input.peek(Paren) { let content; @@ -243,8 +244,15 @@ impl BsnInheritedScene { }; BsnInheritedScene::Fn { path, args } } - _ => { - todo!() + path_type => { + return Err(syn::Error::new( + path.span(), + format!( + "Cannot inherit from path {} of type {:?}", + path_to_string(&path), + path_type, + ), + )) } } }) From f24cf7d46e6cfc850264d617bfeee8faf0d5efe1 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Wed, 29 Apr 2026 13:40:43 -0700 Subject: [PATCH 06/15] Use SceneScope behavior in SceneList impl --- crates/bevy_scene/src/scene_list.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/bevy_scene/src/scene_list.rs b/crates/bevy_scene/src/scene_list.rs index 61739470a2c15..b4f6c73e5e9b3 100644 --- a/crates/bevy_scene/src/scene_list.rs +++ b/crates/bevy_scene/src/scene_list.rs @@ -166,12 +166,12 @@ impl SceneList for SceneScope { scenes: &mut Vec, ) -> Result<(), ResolveSceneError> { let mut resolved_scene = ResolvedScene::default(); - self.0.resolve(context, &mut resolved_scene)?; + self.resolve(context, &mut resolved_scene)?; scenes.push(resolved_scene); Ok(()) } fn register_dependencies(&self, dependencies: &mut SceneDependencies) { - self.0.register_dependencies(dependencies); + Scene::register_dependencies(self, dependencies); } } From 3feed01ec7ea5664cd3c494e20e85d4efb47a41a Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Wed, 29 Apr 2026 14:08:00 -0700 Subject: [PATCH 07/15] Derive Debug + Reflect --- crates/bevy_scene/src/scene.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/bevy_scene/src/scene.rs b/crates/bevy_scene/src/scene.rs index 67a9cab155779..0069c1d7a2dcb 100644 --- a/crates/bevy_scene/src/scene.rs +++ b/crates/bevy_scene/src/scene.rs @@ -7,6 +7,7 @@ use bevy_ecs::{ event::EntityEvent, lifecycle::HookContext, name::Name, + reflect::ReflectComponent, relationship::Relationship, system::IntoObserverSystem, template::{ @@ -14,6 +15,7 @@ use bevy_ecs::{ }, world::DeferredWorld, }; +use bevy_reflect::Reflect; use core::{any::TypeId, marker::PhantomData}; use thiserror::Error; use variadics_please::all_tuples; @@ -595,8 +597,9 @@ impl + Default + Send + Sync + 'static> Scene for } /// A [`Component`] that must always be spawned with a [`Scene`]. -#[derive(Component, Default, Clone)] +#[derive(Component, Default, Clone, Debug, Reflect)] #[cfg_attr(debug_assertions, component(on_add))] +#[reflect(Component)] pub struct SceneComponent { spawned_from_scene: bool, #[cfg(debug_assertions)] From 2fd3e01dbaca586ef71476b4c1047b71be6eb642 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Wed, 29 Apr 2026 16:28:19 -0700 Subject: [PATCH 08/15] SceneComponent -> SceneComponentInfo --- crates/bevy_ecs/macros/src/component.rs | 2 +- crates/bevy_ecs/macros/src/scene.rs | 2 +- crates/bevy_scene/src/scene.rs | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/bevy_ecs/macros/src/component.rs b/crates/bevy_ecs/macros/src/component.rs index 7a04f88fb66d8..0ed26fd784f33 100644 --- a/crates/bevy_ecs/macros/src/component.rs +++ b/crates/bevy_ecs/macros/src/component.rs @@ -339,7 +339,7 @@ pub fn derive_component(input: TokenStream) -> TokenStream { let bevy_scene = bevy_macro_utils::BevyManifest::shared(|manifest| manifest.get_path("bevy_scene")); register_required.push(quote! { - required_components.register_required(|| #bevy_scene::SceneComponent::new::<#struct_name #type_generics>(false)); + required_components.register_required(|| #bevy_scene::SceneComponentInfo::new::<#struct_name #type_generics>(false)); }); crate::scene::derive_scene_constructor( &ast, diff --git a/crates/bevy_ecs/macros/src/scene.rs b/crates/bevy_ecs/macros/src/scene.rs index dac6cbd97bb42..2a5d9f07fb2dd 100644 --- a/crates/bevy_ecs/macros/src/scene.rs +++ b/crates/bevy_ecs/macros/src/scene.rs @@ -64,7 +64,7 @@ pub(crate) fn derive_scene_constructor( ( #scene_impl, #bevy_scene::InitTemplate::<<#struct_name #type_generics as #bevy_ecs::template::FromTemplate>::Template>::default(), - #bevy_scene::template_value(#bevy_scene::SceneComponent::new::<#struct_name #type_generics>(true)), + #bevy_scene::template_value(#bevy_scene::SceneComponentInfo::new::<#struct_name #type_generics>(true)), ) } } diff --git a/crates/bevy_scene/src/scene.rs b/crates/bevy_scene/src/scene.rs index 0069c1d7a2dcb..75f0e7c5151c5 100644 --- a/crates/bevy_scene/src/scene.rs +++ b/crates/bevy_scene/src/scene.rs @@ -600,16 +600,16 @@ impl + Default + Send + Sync + 'static> Scene for #[derive(Component, Default, Clone, Debug, Reflect)] #[cfg_attr(debug_assertions, component(on_add))] #[reflect(Component)] -pub struct SceneComponent { +pub struct SceneComponentInfo { spawned_from_scene: bool, #[cfg(debug_assertions)] component_name: &'static str, } -impl SceneComponent { - /// Creates a new [`SceneComponent`] for the given type `C`. +impl SceneComponentInfo { + /// Creates a new [`SceneComponentInfo`] for the given type `C`. pub fn new(spawned_from_scene: bool) -> Self { - SceneComponent { + SceneComponentInfo { spawned_from_scene, #[cfg(debug_assertions)] component_name: core::any::type_name::(), @@ -617,11 +617,11 @@ impl SceneComponent { } } -impl SceneComponent { +impl SceneComponentInfo { #[cfg(debug_assertions)] fn on_add(world: DeferredWorld, context: HookContext) { if let Ok(entity) = world.get_entity(context.entity) - && let Some(component) = entity.get::() + && let Some(component) = entity.get::() && !component.spawned_from_scene { tracing::error!( From 4eb749239fd9b1b30c287eb3009aaa1f6aaf882a Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Wed, 29 Apr 2026 18:44:18 -0700 Subject: [PATCH 09/15] SceneComponent derive / reusable Component derive / SceneComponent: Component --- crates/bevy_ecs/Cargo.toml | 3 - crates/bevy_ecs/macro_logic/Cargo.toml | 24 + crates/bevy_ecs/macro_logic/LICENSE-APACHE | 176 +++ crates/bevy_ecs/macro_logic/LICENSE-MIT | 19 + crates/bevy_ecs/macro_logic/src/component.rs | 788 ++++++++++++ crates/bevy_ecs/macro_logic/src/lib.rs | 8 + .../bevy_ecs/macro_logic/src/map_entities.rs | 153 +++ crates/bevy_ecs/macros/Cargo.toml | 4 +- crates/bevy_ecs/macros/src/component.rs | 1088 ----------------- crates/bevy_ecs/macros/src/lib.rs | 21 +- crates/bevy_ecs/macros/src/resource.rs | 36 + crates/bevy_ecs/macros/src/scene.rs | 72 -- crates/bevy_ecs/src/template.rs | 2 +- crates/bevy_feathers/src/controls/button.rs | 10 +- crates/bevy_feathers/src/controls/checkbox.rs | 4 +- .../bevy_feathers/src/controls/color_plane.rs | 5 +- .../src/controls/color_slider.rs | 6 +- .../src/controls/color_swatch.rs | 3 +- .../src/controls/disclosure_toggle.rs | 6 +- crates/bevy_feathers/src/controls/menu.rs | 18 +- .../src/controls/number_input.rs | 4 +- crates/bevy_feathers/src/controls/radio.rs | 4 +- crates/bevy_feathers/src/controls/slider.rs | 4 +- .../bevy_feathers/src/controls/text_input.rs | 8 +- .../src/controls/toggle_switch.rs | 3 +- .../src/controls/virtual_keyboard.rs | 4 +- crates/bevy_macro_utils/src/lib.rs | 2 + crates/bevy_macro_utils/src/path_type.rs | 195 +++ crates/bevy_scene/Cargo.toml | 4 +- crates/bevy_scene/macros/Cargo.toml | 2 + crates/bevy_scene/macros/src/bsn/codegen.rs | 4 +- crates/bevy_scene/macros/src/bsn/parse.rs | 187 +-- crates/bevy_scene/macros/src/lib.rs | 11 + .../bevy_scene/macros/src/scene_component.rs | 122 ++ crates/bevy_scene/src/lib.rs | 27 +- crates/bevy_scene/src/scene.rs | 63 - crates/bevy_scene/src/scene_component.rs | 60 + 37 files changed, 1663 insertions(+), 1487 deletions(-) create mode 100644 crates/bevy_ecs/macro_logic/Cargo.toml create mode 100644 crates/bevy_ecs/macro_logic/LICENSE-APACHE create mode 100644 crates/bevy_ecs/macro_logic/LICENSE-MIT create mode 100644 crates/bevy_ecs/macro_logic/src/component.rs create mode 100644 crates/bevy_ecs/macro_logic/src/lib.rs create mode 100644 crates/bevy_ecs/macro_logic/src/map_entities.rs delete mode 100644 crates/bevy_ecs/macros/src/component.rs create mode 100644 crates/bevy_ecs/macros/src/resource.rs delete mode 100644 crates/bevy_ecs/macros/src/scene.rs create mode 100644 crates/bevy_macro_utils/src/path_type.rs create mode 100644 crates/bevy_scene/macros/src/scene_component.rs create mode 100644 crates/bevy_scene/src/scene_component.rs diff --git a/crates/bevy_ecs/Cargo.toml b/crates/bevy_ecs/Cargo.toml index 07d5bdbe85a2c..cc4d7dadc7140 100644 --- a/crates/bevy_ecs/Cargo.toml +++ b/crates/bevy_ecs/Cargo.toml @@ -25,9 +25,6 @@ serialize = ["dep:serde", "bevy_platform/serialize", "indexmap/serde"] ## Adds runtime reflection support using `bevy_reflect`. bevy_reflect = ["dep:bevy_reflect"] -## Enables support for deriving `bevy_scene` scenes for Components -derive_scene = ["bevy_ecs_macros/scene"] - ## Extends reflection support to functions. reflect_functions = ["bevy_reflect", "bevy_reflect/functions"] reflect_auto_register = ["bevy_reflect", "bevy_reflect/auto_register"] diff --git a/crates/bevy_ecs/macro_logic/Cargo.toml b/crates/bevy_ecs/macro_logic/Cargo.toml new file mode 100644 index 0000000000000..20f3dc038a8f2 --- /dev/null +++ b/crates/bevy_ecs/macro_logic/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bevy_ecs_macro_logic" +version = "0.19.0-dev" +description = "Shared Bevy ECS Macro internals" +edition = "2024" +license = "MIT OR Apache-2.0" + +[dependencies] +bevy_macro_utils = { path = "../../bevy_macro_utils", version = "0.19.0-dev" } + +syn = { version = "2.0.108", features = ["full", "extra-traits"] } +quote = "1.0" +proc-macro2 = "1.0" + +[lints] +workspace = true + +[package.metadata.docs.rs] +rustdoc-args = [ + "-Zunstable-options", + "--generate-link-to-definition", + "--generate-macro-expansion", +] +all-features = true diff --git a/crates/bevy_ecs/macro_logic/LICENSE-APACHE b/crates/bevy_ecs/macro_logic/LICENSE-APACHE new file mode 100644 index 0000000000000..d9a10c0d8e868 --- /dev/null +++ b/crates/bevy_ecs/macro_logic/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/crates/bevy_ecs/macro_logic/LICENSE-MIT b/crates/bevy_ecs/macro_logic/LICENSE-MIT new file mode 100644 index 0000000000000..9cf106272ac3b --- /dev/null +++ b/crates/bevy_ecs/macro_logic/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/bevy_ecs/macro_logic/src/component.rs b/crates/bevy_ecs/macro_logic/src/component.rs new file mode 100644 index 0000000000000..afa9dfe054911 --- /dev/null +++ b/crates/bevy_ecs/macro_logic/src/component.rs @@ -0,0 +1,788 @@ +use proc_macro2::{Span, TokenStream}; +use quote::{quote, ToTokens}; +use std::collections::HashSet; +use syn::{ + braced, parenthesized, + parse::Parse, + parse_quote, + punctuated::Punctuated, + spanned::Spanned, + token::{Brace, Comma, Paren}, + Data, DataStruct, DeriveInput, Expr, ExprCall, ExprPath, Field, Fields, Ident, LitStr, Member, + Path, Result, Token, Type, Visibility, +}; + +use crate::map_entities::{map_entities, MapEntitiesAttributeKind}; + +/// Derived `Component` trait specification, which can be used to generate a component implementation. +pub struct DeriveComponent { + /// The storage type of the component. + pub storage: StorageTy, + /// The parsed punctuated list of required components. + pub requires: Option>, + /// The `on_add` hook. + pub on_add: Option, + /// The `on_insert` hook. + pub on_insert: Option, + /// The `on_discard` hook. + pub on_discard: Option, + /// The `on_remove` hook. + pub on_remove: Option, + /// The `on_despawn` hook. + pub on_despawn: Option, + /// The relationship attribute information. + pub relationship: Option, + /// The relationship target attribute information. + pub relationship_target: Option, + /// Whether or not this component is immutable. + pub immutable: bool, + /// The clone behavior for this component. + pub clone_behavior: Option, + /// The `map_entities` attribute information. + pub map_entities: Option, + /// Additional required component registrations that are added in `Component::register_required_components` + pub additional_requires: Vec, +} + +impl DeriveComponent { + /// Parse [`DeriveComponent`] from the given `ast`. + pub fn parse(ast: &DeriveInput) -> Result { + let mut attrs = DeriveComponent { + storage: StorageTy::Table, + on_add: None, + on_insert: None, + on_discard: None, + on_remove: None, + on_despawn: None, + requires: None, + relationship: None, + relationship_target: None, + immutable: false, + clone_behavior: None, + map_entities: None, + additional_requires: Vec::new(), + }; + + let mut require_paths = HashSet::new(); + for attr in ast.attrs.iter() { + if attr.path().is_ident(COMPONENT) { + attr.parse_nested_meta(|nested| { + if nested.path.is_ident(STORAGE) { + attrs.storage = match nested.value()?.parse::()?.value() { + s if s == TABLE => StorageTy::Table, + s if s == SPARSE_SET => StorageTy::SparseSet, + s => { + return Err(nested.error(format!( + "Invalid storage type `{s}`, expected '{TABLE}' or '{SPARSE_SET}'.", + ))); + } + }; + Ok(()) + } else if nested.path.is_ident(ON_ADD) { + attrs.on_add = Some(HookAttributeKind::parse(nested.input, || { + parse_quote! { Self::on_add } + })?); + Ok(()) + } else if nested.path.is_ident(ON_INSERT) { + attrs.on_insert = Some(HookAttributeKind::parse(nested.input, || { + parse_quote! { Self::on_insert } + })?); + Ok(()) + } else if nested.path.is_ident(ON_DISCARD) { + attrs.on_discard = Some(HookAttributeKind::parse(nested.input, || { + parse_quote! { Self::on_discard } + })?); + Ok(()) + } else if nested.path.is_ident(ON_REMOVE) { + attrs.on_remove = Some(HookAttributeKind::parse(nested.input, || { + parse_quote! { Self::on_remove } + })?); + Ok(()) + } else if nested.path.is_ident(ON_DESPAWN) { + attrs.on_despawn = Some(HookAttributeKind::parse(nested.input, || { + parse_quote! { Self::on_despawn } + })?); + Ok(()) + } else if nested.path.is_ident(IMMUTABLE) { + attrs.immutable = true; + Ok(()) + } else if nested.path.is_ident(CLONE_BEHAVIOR) { + attrs.clone_behavior = Some(nested.value()?.parse()?); + Ok(()) + } else if nested.path.is_ident(MAP_ENTITIES) { + attrs.map_entities = + Some(nested.input.parse::()?); + Ok(()) + } else { + Err(nested.error("Unsupported attribute")) + } + })?; + } else if attr.path().is_ident(REQUIRE) { + let punctuated = + attr.parse_args_with(Punctuated::::parse_terminated)?; + for require in punctuated.iter() { + if !require_paths.insert(require.path.to_token_stream().to_string()) { + return Err(syn::Error::new( + require.path.span(), + "Duplicate required components are not allowed.", + )); + } + } + if let Some(current) = &mut attrs.requires { + current.extend(punctuated); + } else { + attrs.requires = Some(punctuated); + } + } else if attr.path().is_ident(RELATIONSHIP) { + let relationship = attr.parse_args::()?; + attrs.relationship = Some(relationship); + } else if attr.path().is_ident(RELATIONSHIP_TARGET) { + let relationship_target = attr.parse_args::()?; + attrs.relationship_target = Some(relationship_target); + } + } + + if attrs.relationship_target.is_some() && attrs.clone_behavior.is_some() { + return Err(syn::Error::new( + attrs.clone_behavior.span(), + "A Relationship Target already has its own clone behavior, please remove `clone_behavior = ...`", + )); + } + + Ok(attrs) + } + + /// Generates a new `Component` trait implementation from this specification. + /// + /// Note that this will add Send + Sync + 'static to the where clause + pub fn impl_component(self, ast: &mut DeriveInput, bevy_ecs: &Path) -> TokenStream { + let relationship = match self.derive_relationship(ast, bevy_ecs) { + Ok(value) => value, + Err(err) => Some(err.into_compile_error()), + }; + let relationship_target = match self.derive_relationship_target(ast, bevy_ecs) { + Ok(value) => value, + Err(err) => Some(err.into_compile_error()), + }; + + let map_entities = map_entities( + &ast.data, + bevy_ecs, + Ident::new("this", Span::call_site()), + relationship.is_some(), + relationship_target.is_some(), + self.map_entities, + ) + .map(|map_entities_impl| { + quote! { + fn map_entities(this: &mut Self, mapper: &mut M) { + use #bevy_ecs::entity::MapEntities; + #map_entities_impl + } + } + }); + + let storage = storage_path(bevy_ecs, self.storage); + + let on_add_path = self.on_add.map(|path| path.to_token_stream(bevy_ecs)); + let on_remove_path = self.on_remove.map(|path| path.to_token_stream(bevy_ecs)); + + let on_insert_path = if relationship.is_some() { + if self.on_insert.is_some() { + return syn::Error::new( + ast.span(), + "Custom on_insert hooks are not supported as relationships already define an on_insert hook", + ) + .into_compile_error(); + } + + Some(quote!(::on_insert)) + } else { + self.on_insert.map(|path| path.to_token_stream(bevy_ecs)) + }; + + let on_discard_path = if relationship.is_some() { + if self.on_discard.is_some() { + return syn::Error::new( + ast.span(), + "Custom on_discard hooks are not supported as Relationships already define an on_discard hook", + ) + .into_compile_error(); + } + + Some(quote!(::on_discard)) + } else if self.relationship_target.is_some() { + if self.on_discard.is_some() { + return syn::Error::new( + ast.span(), + "Custom on_discard hooks are not supported as RelationshipTarget already defines an on_discard hook", + ) + .into_compile_error(); + } + + Some(quote!(::on_discard)) + } else { + self.on_discard.map(|path| path.to_token_stream(bevy_ecs)) + }; + + let on_despawn_path = if self + .relationship_target + .is_some_and(|target| target.linked_spawn) + { + if self.on_despawn.is_some() { + return syn::Error::new( + ast.span(), + "Custom on_despawn hooks are not supported as this RelationshipTarget already defines an on_despawn hook, via the 'linked_spawn' attribute", + ) + .into_compile_error(); + } + + Some(quote!(::on_despawn)) + } else { + self.on_despawn.map(|path| path.to_token_stream(bevy_ecs)) + }; + + let on_add = hook_register_function_call(bevy_ecs, quote! {on_add}, on_add_path); + let on_insert = hook_register_function_call(bevy_ecs, quote! {on_insert}, on_insert_path); + let on_discard = + hook_register_function_call(bevy_ecs, quote! {on_discard}, on_discard_path); + let on_remove = hook_register_function_call(bevy_ecs, quote! {on_remove}, on_remove_path); + let on_despawn = + hook_register_function_call(bevy_ecs, quote! {on_despawn}, on_despawn_path); + + let requires = &self.requires; + let mut register_required = Vec::with_capacity(self.requires.iter().len()); + if let Some(requires) = requires { + for require in requires { + let ident = &require.path; + let constructor = match &require.func { + Some(func) => quote! { || { let x: #ident = (#func)().into(); x } }, + None => quote! { <#ident as ::core::default::Default>::default }, + }; + register_required.push(quote! { + required_components.register_required::<#ident>(#constructor); + }); + } + } + let additional_requires = &self.additional_requires; + let struct_name = &ast.ident; + ast.generics + .make_where_clause() + .predicates + .push(parse_quote! { Self: ::core::marker::Send + ::core::marker::Sync + 'static }); + let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); + + let required_component_docs = self.requires.map(|r| { + let paths = r + .iter() + .map(|r| format!("[`{}`]", r.path.to_token_stream())) + .collect::>() + .join(", "); + let doc = format!("**Required Components**: {paths}. \n\n A component's Required Components are inserted whenever it is inserted. Note that this will also insert the required components _of_ the required components, recursively, in depth-first order."); + quote! { + #[doc = #doc] + } + }); + + let mutable_type = (self.immutable || relationship.is_some()) + .then_some(quote! { #bevy_ecs::component::Immutable }) + .unwrap_or(quote! { #bevy_ecs::component::Mutable }); + + let clone_behavior = if relationship_target.is_some() || relationship.is_some() { + quote!( + use #bevy_ecs::relationship::{ + RelationshipCloneBehaviorBase, RelationshipCloneBehaviorViaClone, RelationshipCloneBehaviorViaReflect, + RelationshipTargetCloneBehaviorViaClone, RelationshipTargetCloneBehaviorViaReflect, RelationshipTargetCloneBehaviorHierarchy + }; + (&&&&&&&#bevy_ecs::relationship::RelationshipCloneBehaviorSpecialization::::default()).default_clone_behavior() + ) + } else if let Some(behavior) = self.clone_behavior { + quote!(#bevy_ecs::component::ComponentCloneBehavior::#behavior) + } else { + quote!( + use #bevy_ecs::component::{DefaultCloneBehaviorBase, DefaultCloneBehaviorViaClone}; + (&&&#bevy_ecs::component::DefaultCloneBehaviorSpecialization::::default()).default_clone_behavior() + ) + }; + + let relationship_accessor = if (relationship.is_some() || relationship_target.is_some()) + && let Data::Struct(DataStruct { + fields, + struct_token, + .. + }) = &ast.data + && let Ok(field) = relationship_field(fields, "Relationship", struct_token.span()) + { + let relationship_member = field.ident.clone().map_or(Member::from(0), Member::Named); + if relationship.is_some() { + quote! { + ::core::option::Option::Some( + // Safety: we pass valid offset of a field containing Entity (obtained via offset_off!) + unsafe { + #bevy_ecs::relationship::ComponentRelationshipAccessor::::relationship( + ::core::mem::offset_of!(Self, #relationship_member) + ) + } + ) + } + } else { + quote! { + ::core::option::Option::Some(#bevy_ecs::relationship::ComponentRelationshipAccessor::::relationship_target()) + } + } + } else { + quote! {::core::option::Option::None} + }; + quote! { + #required_component_docs + impl #impl_generics #bevy_ecs::component::Component for #struct_name #type_generics #where_clause { + const STORAGE_TYPE: #bevy_ecs::component::StorageType = #storage; + type Mutability = #mutable_type; + fn register_required_components( + _requiree: #bevy_ecs::component::ComponentId, + required_components: &mut #bevy_ecs::component::RequiredComponentsRegistrator, + ) { + #(#register_required)* + #(#additional_requires)* + } + + #on_add + #on_insert + #on_discard + #on_remove + #on_despawn + + fn clone_behavior() -> #bevy_ecs::component::ComponentCloneBehavior { + #clone_behavior + } + + #map_entities + + fn relationship_accessor() -> ::core::option::Option<#bevy_ecs::relationship::ComponentRelationshipAccessor> { + #relationship_accessor + } + } + + #relationship + + #relationship_target + } + } + fn derive_relationship( + &self, + ast: &DeriveInput, + bevy_ecs: &Path, + ) -> Result> { + let Some(relationship) = &self.relationship else { + return Ok(None); + }; + let Data::Struct(DataStruct { + fields, + struct_token, + .. + }) = &ast.data + else { + return Err(syn::Error::new( + ast.span(), + "Relationship can only be derived for structs.", + )); + }; + let field = relationship_field(fields, "Relationship", struct_token.span())?; + + let relationship_member = field.ident.clone().map_or(Member::from(0), Member::Named); + let members = fields + .members() + .filter(|member| member != &relationship_member); + + let struct_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); + + let relationship_target = &relationship.relationship_target; + let allow_self_referential = relationship.allow_self_referential; + + Ok(Some(quote! { + impl #impl_generics #bevy_ecs::relationship::Relationship for #struct_name #type_generics #where_clause { + type RelationshipTarget = #relationship_target; + const ALLOW_SELF_REFERENTIAL: bool = #allow_self_referential; + + #[inline(always)] + fn get(&self) -> #bevy_ecs::entity::Entity { + self.#relationship_member + } + + #[inline] + fn from(entity: #bevy_ecs::entity::Entity) -> Self { + Self { + #(#members: ::core::default::Default::default(),)* + #relationship_member: entity + } + } + + #[inline] + fn set_risky(&mut self, entity: #bevy_ecs::entity::Entity) { + self.#relationship_member = entity; + } + } + })) + } + + fn derive_relationship_target( + &self, + ast: &DeriveInput, + bevy_ecs: &Path, + ) -> Result> { + let Some(relationship_target) = &self.relationship_target else { + return Ok(None); + }; + + let Data::Struct(DataStruct { + fields, + struct_token, + .. + }) = &ast.data + else { + return Err(syn::Error::new( + ast.span(), + "RelationshipTarget can only be derived for structs.", + )); + }; + let field = relationship_field(fields, "RelationshipTarget", struct_token.span())?; + + if field.vis != Visibility::Inherited { + return Err(syn::Error::new(field.span(), "The collection in RelationshipTarget must be private to prevent users from directly mutating it, which could invalidate the correctness of relationships.")); + } + let collection = &field.ty; + let relationship_member = field.ident.clone().map_or(Member::from(0), Member::Named); + + let members = fields + .members() + .filter(|member| member != &relationship_member); + + let relationship = &relationship_target.relationship; + let struct_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); + let linked_spawn = relationship_target.linked_spawn; + Ok(Some(quote! { + impl #impl_generics #bevy_ecs::relationship::RelationshipTarget for #struct_name #type_generics #where_clause { + const LINKED_SPAWN: bool = #linked_spawn; + type Relationship = #relationship; + type Collection = #collection; + + #[inline] + fn collection(&self) -> &Self::Collection { + &self.#relationship_member + } + + #[inline] + fn collection_mut_risky(&mut self) -> &mut Self::Collection { + &mut self.#relationship_member + } + + #[inline] + fn from_collection_risky(collection: Self::Collection) -> Self { + Self { + #(#members: ::core::default::Default::default(),)* + #relationship_member: collection + } + } + } + })) + } +} + +const COMPONENT: &str = "component"; +const MAP_ENTITIES: &str = "map_entities"; +const STORAGE: &str = "storage"; +const REQUIRE: &str = "require"; +const RELATIONSHIP: &str = "relationship"; +const RELATIONSHIP_TARGET: &str = "relationship_target"; + +const ON_ADD: &str = "on_add"; +const ON_INSERT: &str = "on_insert"; +const ON_DISCARD: &str = "on_discard"; +const ON_REMOVE: &str = "on_remove"; +const ON_DESPAWN: &str = "on_despawn"; + +const IMMUTABLE: &str = "immutable"; +const CLONE_BEHAVIOR: &str = "clone_behavior"; + +/// All allowed attribute value expression kinds for component hooks. +/// This doesn't simply use general expressions because of conflicting needs: +/// - we want to be able to use `Self` & generic parameters in paths +/// - call expressions producing a closure need to be wrapped in a function +/// to turn them into function pointers, which prevents access to the outer generic params +#[derive(Debug)] +pub enum HookAttributeKind { + /// expressions like function or struct names + /// + /// structs will throw compile errors on the code generation so this is safe + Path(ExprPath), + /// function call like expressions + Call(ExprCall), +} + +impl HookAttributeKind { + fn parse( + input: syn::parse::ParseStream, + default_hook_path: impl FnOnce() -> ExprPath, + ) -> Result { + if input.peek(Token![=]) { + input.parse::()?; + input.parse::().and_then(Self::from_expr) + } else { + Ok(Self::Path(default_hook_path())) + } + } + + fn from_expr(value: Expr) -> Result { + match value { + Expr::Path(path) => Ok(HookAttributeKind::Path(path)), + Expr::Call(call) => Ok(HookAttributeKind::Call(call)), + // throw meaningful error on all other expressions + _ => Err(syn::Error::new( + value.span(), + [ + "Not supported in this position, please use one of the following:", + "- path to function", + "- call to function yielding closure", + ] + .join("\n"), + )), + } + } + + fn to_token_stream(&self, bevy_ecs_path: &Path) -> TokenStream { + match self { + HookAttributeKind::Path(path) => path.to_token_stream(), + HookAttributeKind::Call(call) => { + quote!({ + fn _internal_hook(world: #bevy_ecs_path::world::DeferredWorld, ctx: #bevy_ecs_path::lifecycle::HookContext) { + (#call)(world, ctx) + } + _internal_hook + }) + } + } + } +} + +/// The derived component storage type +#[derive(Clone, Copy)] +pub enum StorageTy { + /// Table storage + Table, + /// Sparse set storage + SparseSet, +} + +/// Derived required component from the `#[require]` attribute. +pub struct Require { + path: Path, + func: Option, +} + +/// Derived `#[relationship]` attribute information. +pub struct Relationship { + relationship_target: Type, + allow_self_referential: bool, +} + +/// Derived `#[relationship_target]` attribute information. +pub struct RelationshipTarget { + relationship: Type, + linked_spawn: bool, +} + +// values for `storage` attribute +const TABLE: &str = "Table"; +const SPARSE_SET: &str = "SparseSet"; + +impl Parse for Require { + fn parse(input: syn::parse::ParseStream) -> Result { + let mut path = input.parse::()?; + let mut last_segment_is_lower = false; + let mut is_constructor_call = false; + + // Use the case of the type name to check if it's an enum + // This doesn't match everything that can be an enum according to the rust spec + // but it matches what clippy is OK with + let is_enum = { + let mut first_chars = path + .segments + .iter() + .rev() + .filter_map(|s| s.ident.to_string().chars().next()); + if let Some(last) = first_chars.next() { + if last.is_uppercase() { + if let Some(last) = first_chars.next() { + last.is_uppercase() + } else { + false + } + } else { + last_segment_is_lower = true; + false + } + } else { + false + } + }; + + let func = if input.peek(Token![=]) { + // If there is an '=', then this is a "function style" require + input.parse::()?; + let expr: Expr = input.parse()?; + Some(quote!(|| #expr )) + } else if input.peek(Brace) { + // This is a "value style" named-struct-like require + let content; + braced!(content in input); + let content = content.parse::()?; + Some(quote!(|| #path { #content })) + } else if input.peek(Paren) { + // This is a "value style" tuple-struct-like require + let content; + parenthesized!(content in input); + let content = content.parse::()?; + is_constructor_call = last_segment_is_lower; + Some(quote!(|| #path (#content))) + } else if is_enum { + // if this is an enum, then it is an inline enum component declaration + Some(quote!(|| #path)) + } else { + // if this isn't any of the above, then it is a component ident, which will use Default + None + }; + if is_enum || is_constructor_call { + path.segments.pop(); + path.segments.pop_punct(); + } + Ok(Require { path, func }) + } +} + +fn storage_path(bevy_ecs_path: &Path, ty: StorageTy) -> TokenStream { + let storage_type = match ty { + StorageTy::Table => Ident::new("Table", Span::call_site()), + StorageTy::SparseSet => Ident::new("SparseSet", Span::call_site()), + }; + + quote! { #bevy_ecs_path::component::StorageType::#storage_type } +} + +fn hook_register_function_call( + bevy_ecs_path: &Path, + hook: TokenStream, + function: Option, +) -> Option { + function.map(|meta| { + quote! { + fn #hook() -> ::core::option::Option<#bevy_ecs_path::lifecycle::ComponentHook> { + ::core::option::Option::Some(#meta) + } + } + }) +} + +mod kw { + syn::custom_keyword!(relationship_target); + syn::custom_keyword!(relationship); + syn::custom_keyword!(linked_spawn); + syn::custom_keyword!(allow_self_referential); +} + +impl Parse for Relationship { + fn parse(input: syn::parse::ParseStream) -> Result { + let mut relationship_target: Option = None; + let mut allow_self_referential: bool = false; + + while !input.is_empty() { + let lookahead = input.lookahead1(); + if lookahead.peek(kw::allow_self_referential) { + input.parse::()?; + allow_self_referential = true; + } else if lookahead.peek(kw::relationship_target) { + input.parse::()?; + input.parse::()?; + relationship_target = Some(input.parse()?); + } else { + return Err(lookahead.error()); + } + if !input.is_empty() { + input.parse::()?; + } + } + Ok(Relationship { + relationship_target: relationship_target.ok_or_else(|| { + syn::Error::new(input.span(), "Missing `relationship_target = X` attribute") + })?, + allow_self_referential, + }) + } +} + +impl Parse for RelationshipTarget { + fn parse(input: syn::parse::ParseStream) -> Result { + let mut relationship: Option = None; + let mut linked_spawn: bool = false; + + while !input.is_empty() { + let lookahead = input.lookahead1(); + if lookahead.peek(kw::linked_spawn) { + input.parse::()?; + linked_spawn = true; + } else if lookahead.peek(kw::relationship) { + input.parse::()?; + input.parse::()?; + relationship = Some(input.parse()?); + } else { + return Err(lookahead.error()); + } + if !input.is_empty() { + input.parse::()?; + } + } + Ok(RelationshipTarget { + relationship: relationship.ok_or_else(|| { + syn::Error::new(input.span(), "Missing `relationship = X` attribute") + })?, + linked_spawn, + }) + } +} + +/// Returns the field with the `#[relationship]` attribute, the only field if unnamed, +/// or the only field in a [`Fields::Named`] with one field, otherwise `Err`. +pub(crate) fn relationship_field<'a>( + fields: &'a Fields, + derive: &'static str, + span: Span, +) -> Result<&'a Field> { + match fields { + Fields::Named(fields) if fields.named.len() == 1 => Ok(fields.named.first().unwrap()), + Fields::Named(fields) => fields.named.iter().find(|field| { + field + .attrs + .iter() + .any(|attr| attr.path().is_ident(RELATIONSHIP)) + }).ok_or(syn::Error::new( + span, + format!("{derive} derive expected named structs with a single field or with a field annotated with #[relationship].") + )), + Fields::Unnamed(fields) if fields.unnamed.len() == 1 => Ok(fields.unnamed.first().unwrap()), + Fields::Unnamed(fields) => fields.unnamed.iter().find(|field| { + field + .attrs + .iter() + .any(|attr| attr.path().is_ident(RELATIONSHIP)) + }) + .ok_or(syn::Error::new( + span, + format!("{derive} derive expected unnamed structs with one field or with a field annotated with #[relationship]."), + )), + Fields::Unit => Err(syn::Error::new( + span, + format!("{derive} derive expected named or unnamed struct, found unit struct."), + )), + } +} diff --git a/crates/bevy_ecs/macro_logic/src/lib.rs b/crates/bevy_ecs/macro_logic/src/lib.rs new file mode 100644 index 0000000000000..b41882b90dbd5 --- /dev/null +++ b/crates/bevy_ecs/macro_logic/src/lib.rs @@ -0,0 +1,8 @@ +//! Reusable `bevy_ecs` macro logic. This enables defining derives that internally derive ECS traits +//! like `Component`. + +/// `Component` macro logic. The primary interface is [`DeriveComponent`](component::DeriveComponent). +pub mod component; + +/// `MapEntities` macro logic. +pub mod map_entities; diff --git a/crates/bevy_ecs/macro_logic/src/map_entities.rs b/crates/bevy_ecs/macro_logic/src/map_entities.rs new file mode 100644 index 0000000000000..7447319ef9257 --- /dev/null +++ b/crates/bevy_ecs/macro_logic/src/map_entities.rs @@ -0,0 +1,153 @@ +use proc_macro2::TokenStream; +use quote::{format_ident, quote, ToTokens}; +use syn::{ + parse::Parse, spanned::Spanned, Data, DataEnum, DataStruct, Expr, ExprPath, Ident, Member, + Path, Token, +}; + +use crate::component::relationship_field; + +const ENTITIES: &str = "entities"; + +/// Implements `MapEntities` +pub fn map_entities( + data: &Data, + bevy_ecs: &Path, + self_ident: Ident, + is_relationship: bool, + is_relationship_target: bool, + map_entities_attr: Option, +) -> Option { + if let Some(map_entities_override) = map_entities_attr { + let map_entities_tokens = map_entities_override.to_token_stream(bevy_ecs); + return Some(quote!( + #map_entities_tokens(#self_ident, mapper) + )); + } + + match data { + Data::Struct(DataStruct { fields, .. }) => { + let mut map = Vec::with_capacity(fields.len()); + + let relationship = if is_relationship || is_relationship_target { + relationship_field(fields, "MapEntities", fields.span()).ok() + } else { + None + }; + fields + .iter() + .enumerate() + .filter(|(_, field)| { + field.attrs.iter().any(|a| a.path().is_ident(ENTITIES)) + || relationship.is_some_and(|relationship| relationship == *field) + }) + .for_each(|(index, field)| { + let field_member = field + .ident + .clone() + .map_or(Member::from(index), Member::Named); + + map.push(quote!(#self_ident.#field_member.map_entities(mapper);)); + }); + if map.is_empty() { + return None; + }; + Some(quote!( + #(#map)* + )) + } + Data::Enum(DataEnum { variants, .. }) => { + let mut map = Vec::with_capacity(variants.len()); + + for variant in variants.iter() { + let field_members = variant + .fields + .iter() + .enumerate() + .filter(|(_, field)| field.attrs.iter().any(|a| a.path().is_ident(ENTITIES))) + .map(|(index, field)| { + field + .ident + .clone() + .map_or(Member::from(index), Member::Named) + }) + .collect::>(); + + let ident = &variant.ident; + let field_idents = field_members + .iter() + .map(|member| format_ident!("__self{}", member)) + .collect::>(); + + map.push( + quote!(Self::#ident {#(#field_members: #field_idents,)* ..} => { + #(#field_idents.map_entities(mapper);)* + }), + ); + } + + if map.is_empty() { + return None; + }; + + Some(quote!( + match #self_ident { + #(#map,)* + _ => {} + } + )) + } + Data::Union(_) => None, + } +} + +/// The type of `MapEntities` attribute. +#[derive(Debug)] +pub enum MapEntitiesAttributeKind { + /// expressions like function or struct names + /// + /// structs will throw compile errors on the code generation so this is safe + Path(ExprPath), + /// When no value is specified + Default, +} + +impl MapEntitiesAttributeKind { + fn from_expr(value: Expr) -> syn::Result { + match value { + Expr::Path(path) => Ok(Self::Path(path)), + // throw meaningful error on all other expressions + _ => Err(syn::Error::new( + value.span(), + [ + "Not supported in this position, please use one of the following:", + "- path to function", + "- nothing to default to MapEntities implementation", + ] + .join("\n"), + )), + } + } + + fn to_token_stream(&self, bevy_ecs_path: &Path) -> TokenStream { + match self { + MapEntitiesAttributeKind::Path(path) => path.to_token_stream(), + MapEntitiesAttributeKind::Default => { + quote!( + ::map_entities + ) + } + } + } +} + +impl Parse for MapEntitiesAttributeKind { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + if input.peek(Token![=]) { + input.parse::()?; + input.parse::().and_then(Self::from_expr) + } else { + Ok(Self::Default) + } + } +} diff --git a/crates/bevy_ecs/macros/Cargo.toml b/crates/bevy_ecs/macros/Cargo.toml index a18af3aeff24b..33367dbe6b9da 100644 --- a/crates/bevy_ecs/macros/Cargo.toml +++ b/crates/bevy_ecs/macros/Cargo.toml @@ -8,11 +8,9 @@ license = "MIT OR Apache-2.0" [lib] proc-macro = true -[features] -scene = [] - [dependencies] bevy_macro_utils = { path = "../../bevy_macro_utils", version = "0.19.0-dev" } +bevy_ecs_macro_logic = { path = "../macro_logic", version = "0.19.0-dev" } syn = { version = "2.0.108", features = ["full", "extra-traits"] } quote = "1.0" diff --git a/crates/bevy_ecs/macros/src/component.rs b/crates/bevy_ecs/macros/src/component.rs deleted file mode 100644 index 0ed26fd784f33..0000000000000 --- a/crates/bevy_ecs/macros/src/component.rs +++ /dev/null @@ -1,1088 +0,0 @@ -use proc_macro::TokenStream; -use proc_macro2::{Span, TokenStream as TokenStream2}; -use quote::{format_ident, quote, ToTokens}; -use std::collections::HashSet; -use syn::{ - braced, parenthesized, - parse::Parse, - parse_macro_input, parse_quote, - punctuated::Punctuated, - spanned::Spanned, - token::{Brace, Comma, Paren}, - Data, DataEnum, DataStruct, DeriveInput, Expr, ExprCall, ExprPath, Field, Fields, Ident, - LitStr, Member, Path, Result, Token, Type, Visibility, -}; - -pub fn derive_resource(input: TokenStream) -> TokenStream { - let mut ast = parse_macro_input!(input as DeriveInput); - let bevy_ecs_path: Path = crate::bevy_ecs_path(); - - // We want to raise a compile time error when the generic lifetimes - // are not bound to 'static lifetime - let non_static_lifetime_error = ast - .generics - .lifetimes() - .filter(|lifetime| !lifetime.bounds.iter().any(|bound| bound.ident == "static")) - .map(|param| syn::Error::new(param.span(), "Lifetimes must be 'static")) - .reduce(|mut err_acc, err| { - err_acc.combine(err); - err_acc - }); - if let Some(err) = non_static_lifetime_error { - return err.into_compile_error().into(); - } - - // Implement the Component trait. - let map_entities = map_entities( - &ast.data, - &bevy_ecs_path, - Ident::new("this", Span::call_site()), - false, - false, - None - ).map(|map_entities_impl| quote! { - fn map_entities(this: &mut Self, mapper: &mut M) { - use #bevy_ecs_path::entity::MapEntities; - #map_entities_impl - } - }); - - let storage = storage_path(&bevy_ecs_path, StorageTy::Table); - - let on_add_path = None; - let on_remove_path = None; - let on_insert_path = None; - let on_replace_path = None; - let on_despawn_path = None; - - let on_add = hook_register_function_call(&bevy_ecs_path, quote! {on_add}, on_add_path); - let on_remove = hook_register_function_call(&bevy_ecs_path, quote! {on_remove}, on_remove_path); - let on_insert = hook_register_function_call(&bevy_ecs_path, quote! {on_insert}, on_insert_path); - let on_replace = - hook_register_function_call(&bevy_ecs_path, quote! {on_replace}, on_replace_path); - let on_despawn = - hook_register_function_call(&bevy_ecs_path, quote! {on_despawn}, on_despawn_path); - - ast.generics - .make_where_clause() - .predicates - .push(parse_quote! { Self: ::core::marker::Send + ::core::marker::Sync + 'static }); - - let struct_name = &ast.ident; - let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); - - let mut register_required = Vec::with_capacity(1); - // We add the component_id existence check here to avoid recursive init during required components initialization. - register_required.push(quote! { - let resource_component_id = if let ::core::option::Option::Some(id) = required_components.components_registrator().component_id::<#struct_name #type_generics>() { - id - } else { - required_components.components_registrator().register_component::<#struct_name #type_generics>() - }; - required_components.register_required::<#bevy_ecs_path::resource::IsResource>(move || #bevy_ecs_path::resource::IsResource::new(resource_component_id)); - }); - - // This puts `register_required` before `register_recursive_requires` to ensure that the constructors of _all_ top - // level components are initialized first, giving them precedence over recursively defined constructors for the same component type - let component_derive_token_stream = TokenStream::from(quote! { - impl #impl_generics #bevy_ecs_path::component::Component for #struct_name #type_generics #where_clause { - const STORAGE_TYPE: #bevy_ecs_path::component::StorageType = #storage; - type Mutability = #bevy_ecs_path::component::Mutable; - fn register_required_components( - _requiree: #bevy_ecs_path::component::ComponentId, - required_components: &mut #bevy_ecs_path::component::RequiredComponentsRegistrator, - ) { - #(#register_required)* - } - - #on_add - #on_insert - #on_replace - #on_remove - #on_despawn - - fn clone_behavior() -> #bevy_ecs_path::component::ComponentCloneBehavior { - #bevy_ecs_path::component::ComponentCloneBehavior::Default - } - - #map_entities - - fn relationship_accessor() -> ::core::option::Option<#bevy_ecs_path::relationship::ComponentRelationshipAccessor> { - ::core::option::Option::None - } - } - }); - - // Implement the Resource trait. - let resource_impl_token_stream = TokenStream::from(quote! { - impl #impl_generics #bevy_ecs_path::resource::Resource for #struct_name #type_generics #where_clause { - } - }); - - resource_impl_token_stream - .into_iter() - .chain(component_derive_token_stream) - .collect() -} - -/// Component derive syntax is documented on both the macro and the trait. -pub fn derive_component(input: TokenStream) -> TokenStream { - let mut ast = parse_macro_input!(input as DeriveInput); - let bevy_ecs_path: Path = crate::bevy_ecs_path(); - - let attrs = match parse_component_attr(&ast) { - Ok(attrs) => attrs, - Err(e) => return e.into_compile_error().into(), - }; - - let relationship = match derive_relationship(&ast, &attrs, &bevy_ecs_path) { - Ok(value) => value, - Err(err) => err.into_compile_error().into(), - }; - let relationship_target = match derive_relationship_target(&ast, &attrs, &bevy_ecs_path) { - Ok(value) => value, - Err(err) => err.into_compile_error().into(), - }; - - let map_entities = map_entities( - &ast.data, - &bevy_ecs_path, - Ident::new("this", Span::call_site()), - relationship.is_some(), - relationship_target.is_some(), - attrs.map_entities - ).map(|map_entities_impl| quote! { - fn map_entities(this: &mut Self, mapper: &mut M) { - use #bevy_ecs_path::entity::MapEntities; - #map_entities_impl - } - }); - - let storage = storage_path(&bevy_ecs_path, attrs.storage); - - let on_add_path = attrs - .on_add - .map(|path| path.to_token_stream(&bevy_ecs_path)); - let on_remove_path = attrs - .on_remove - .map(|path| path.to_token_stream(&bevy_ecs_path)); - - let on_insert_path = if relationship.is_some() { - if attrs.on_insert.is_some() { - return syn::Error::new( - ast.span(), - "Custom on_insert hooks are not supported as relationships already define an on_insert hook", - ) - .into_compile_error() - .into(); - } - - Some(quote!(::on_insert)) - } else { - attrs - .on_insert - .map(|path| path.to_token_stream(&bevy_ecs_path)) - }; - - let on_discard_path = if relationship.is_some() { - if attrs.on_discard.is_some() { - return syn::Error::new( - ast.span(), - "Custom on_discard hooks are not supported as Relationships already define an on_discard hook", - ) - .into_compile_error() - .into(); - } - - Some(quote!(::on_discard)) - } else if attrs.relationship_target.is_some() { - if attrs.on_discard.is_some() { - return syn::Error::new( - ast.span(), - "Custom on_discard hooks are not supported as RelationshipTarget already defines an on_discard hook", - ) - .into_compile_error() - .into(); - } - - Some(quote!(::on_discard)) - } else { - attrs - .on_discard - .map(|path| path.to_token_stream(&bevy_ecs_path)) - }; - - let on_despawn_path = if attrs - .relationship_target - .is_some_and(|target| target.linked_spawn) - { - if attrs.on_despawn.is_some() { - return syn::Error::new( - ast.span(), - "Custom on_despawn hooks are not supported as this RelationshipTarget already defines an on_despawn hook, via the 'linked_spawn' attribute", - ) - .into_compile_error() - .into(); - } - - Some(quote!(::on_despawn)) - } else { - attrs - .on_despawn - .map(|path| path.to_token_stream(&bevy_ecs_path)) - }; - - let on_add = hook_register_function_call(&bevy_ecs_path, quote! {on_add}, on_add_path); - let on_insert = hook_register_function_call(&bevy_ecs_path, quote! {on_insert}, on_insert_path); - let on_discard = - hook_register_function_call(&bevy_ecs_path, quote! {on_discard}, on_discard_path); - let on_remove = hook_register_function_call(&bevy_ecs_path, quote! {on_remove}, on_remove_path); - let on_despawn = - hook_register_function_call(&bevy_ecs_path, quote! {on_despawn}, on_despawn_path); - - ast.generics - .make_where_clause() - .predicates - .push(parse_quote! { Self: ::core::marker::Send + ::core::marker::Sync + 'static }); - - let requires = &attrs.requires; - let mut register_required = Vec::with_capacity(attrs.requires.iter().len()); - if let Some(requires) = requires { - for require in requires { - let ident = &require.path; - let constructor = match &require.func { - Some(func) => quote! { || { let x: #ident = (#func)().into(); x } }, - None => quote! { <#ident as ::core::default::Default>::default }, - }; - register_required.push(quote! { - required_components.register_required::<#ident>(#constructor); - }); - } - } - let struct_name = &ast.ident; - let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); - - let required_component_docs = attrs.requires.map(|r| { - let paths = r - .iter() - .map(|r| format!("[`{}`]", r.path.to_token_stream())) - .collect::>() - .join(", "); - let doc = format!("**Required Components**: {paths}. \n\n A component's Required Components are inserted whenever it is inserted. Note that this will also insert the required components _of_ the required components, recursively, in depth-first order."); - quote! { - #[doc = #doc] - } - }); - - let mutable_type = (attrs.immutable || relationship.is_some()) - .then_some(quote! { #bevy_ecs_path::component::Immutable }) - .unwrap_or(quote! { #bevy_ecs_path::component::Mutable }); - - let clone_behavior = if relationship_target.is_some() || relationship.is_some() { - quote!( - use #bevy_ecs_path::relationship::{ - RelationshipCloneBehaviorBase, RelationshipCloneBehaviorViaClone, RelationshipCloneBehaviorViaReflect, - RelationshipTargetCloneBehaviorViaClone, RelationshipTargetCloneBehaviorViaReflect, RelationshipTargetCloneBehaviorHierarchy - }; - (&&&&&&&#bevy_ecs_path::relationship::RelationshipCloneBehaviorSpecialization::::default()).default_clone_behavior() - ) - } else if let Some(behavior) = attrs.clone_behavior { - quote!(#bevy_ecs_path::component::ComponentCloneBehavior::#behavior) - } else { - quote!( - use #bevy_ecs_path::component::{DefaultCloneBehaviorBase, DefaultCloneBehaviorViaClone}; - (&&&#bevy_ecs_path::component::DefaultCloneBehaviorSpecialization::::default()).default_clone_behavior() - ) - }; - - let relationship_accessor = if (relationship.is_some() || relationship_target.is_some()) - && let Data::Struct(DataStruct { - fields, - struct_token, - .. - }) = &ast.data - && let Ok(field) = relationship_field(fields, "Relationship", struct_token.span()) - { - let relationship_member = field.ident.clone().map_or(Member::from(0), Member::Named); - if relationship.is_some() { - quote! { - ::core::option::Option::Some( - // Safety: we pass valid offset of a field containing Entity (obtained via offset_off!) - unsafe { - #bevy_ecs_path::relationship::ComponentRelationshipAccessor::::relationship( - ::core::mem::offset_of!(Self, #relationship_member) - ) - } - ) - } - } else { - quote! { - ::core::option::Option::Some(#bevy_ecs_path::relationship::ComponentRelationshipAccessor::::relationship_target()) - } - } - } else { - quote! {::core::option::Option::None} - }; - - #[cfg(feature = "scene")] - let scene_constructor = { - if attrs.scene.is_some() || attrs.scene_props.is_some() { - use crate::scene::Scene; - - let (scene, scene_props) = match (attrs.scene, attrs.scene_props) { - (None, Some(props)) => (Scene::default_scene_function(), Some(props)), - (Some(scene), None) => (scene, None), - (Some(scene), Some(props)) => (scene, Some(props)), - (None, None) => unreachable!(), - }; - - let bevy_scene = - bevy_macro_utils::BevyManifest::shared(|manifest| manifest.get_path("bevy_scene")); - register_required.push(quote! { - required_components.register_required(|| #bevy_scene::SceneComponentInfo::new::<#struct_name #type_generics>(false)); - }); - crate::scene::derive_scene_constructor( - &ast, - &bevy_ecs_path, - &bevy_scene, - scene, - scene_props, - ) - } else { - proc_macro2::TokenStream::new() - } - }; - #[cfg(not(feature = "scene"))] - let scene_constructor = proc_macro2::TokenStream::new(); - - // This puts `register_required` before `register_recursive_requires` to ensure that the constructors of _all_ top - // level components are initialized first, giving them precedence over recursively defined constructors for the same component type - TokenStream::from(quote! { - #required_component_docs - impl #impl_generics #bevy_ecs_path::component::Component for #struct_name #type_generics #where_clause { - const STORAGE_TYPE: #bevy_ecs_path::component::StorageType = #storage; - type Mutability = #mutable_type; - fn register_required_components( - _requiree: #bevy_ecs_path::component::ComponentId, - required_components: &mut #bevy_ecs_path::component::RequiredComponentsRegistrator, - ) { - #(#register_required)* - } - - #on_add - #on_insert - #on_discard - #on_remove - #on_despawn - - fn clone_behavior() -> #bevy_ecs_path::component::ComponentCloneBehavior { - #clone_behavior - } - - #map_entities - - fn relationship_accessor() -> ::core::option::Option<#bevy_ecs_path::relationship::ComponentRelationshipAccessor> { - #relationship_accessor - } - } - - #relationship - - #relationship_target - - #scene_constructor - }) -} - -const ENTITIES: &str = "entities"; - -pub(crate) fn map_entities( - data: &Data, - bevy_ecs_path: &Path, - self_ident: Ident, - is_relationship: bool, - is_relationship_target: bool, - map_entities_attr: Option, -) -> Option { - if let Some(map_entities_override) = map_entities_attr { - let map_entities_tokens = map_entities_override.to_token_stream(bevy_ecs_path); - return Some(quote!( - #map_entities_tokens(#self_ident, mapper) - )); - } - - match data { - Data::Struct(DataStruct { fields, .. }) => { - let mut map = Vec::with_capacity(fields.len()); - - let relationship = if is_relationship || is_relationship_target { - relationship_field(fields, "MapEntities", fields.span()).ok() - } else { - None - }; - fields - .iter() - .enumerate() - .filter(|(_, field)| { - field.attrs.iter().any(|a| a.path().is_ident(ENTITIES)) - || relationship.is_some_and(|relationship| relationship == *field) - }) - .for_each(|(index, field)| { - let field_member = field - .ident - .clone() - .map_or(Member::from(index), Member::Named); - - map.push(quote!(#self_ident.#field_member.map_entities(mapper);)); - }); - if map.is_empty() { - return None; - }; - Some(quote!( - #(#map)* - )) - } - Data::Enum(DataEnum { variants, .. }) => { - let mut map = Vec::with_capacity(variants.len()); - - for variant in variants.iter() { - let field_members = variant - .fields - .iter() - .enumerate() - .filter(|(_, field)| field.attrs.iter().any(|a| a.path().is_ident(ENTITIES))) - .map(|(index, field)| { - field - .ident - .clone() - .map_or(Member::from(index), Member::Named) - }) - .collect::>(); - - let ident = &variant.ident; - let field_idents = field_members - .iter() - .map(|member| format_ident!("__self{}", member)) - .collect::>(); - - map.push( - quote!(Self::#ident {#(#field_members: #field_idents,)* ..} => { - #(#field_idents.map_entities(mapper);)* - }), - ); - } - - if map.is_empty() { - return None; - }; - - Some(quote!( - match #self_ident { - #(#map,)* - _ => {} - } - )) - } - Data::Union(_) => None, - } -} - -pub const COMPONENT: &str = "component"; -pub const STORAGE: &str = "storage"; -pub const REQUIRE: &str = "require"; -pub const RELATIONSHIP: &str = "relationship"; -pub const RELATIONSHIP_TARGET: &str = "relationship_target"; - -pub const ON_ADD: &str = "on_add"; -pub const ON_INSERT: &str = "on_insert"; -pub const ON_DISCARD: &str = "on_discard"; -pub const ON_REMOVE: &str = "on_remove"; -pub const ON_DESPAWN: &str = "on_despawn"; -pub const MAP_ENTITIES: &str = "map_entities"; - -pub const IMMUTABLE: &str = "immutable"; -pub const CLONE_BEHAVIOR: &str = "clone_behavior"; - -/// All allowed attribute value expression kinds for component hooks. -/// This doesn't simply use general expressions because of conflicting needs: -/// - we want to be able to use `Self` & generic parameters in paths -/// - call expressions producing a closure need to be wrapped in a function -/// to turn them into function pointers, which prevents access to the outer generic params -#[derive(Debug)] -enum HookAttributeKind { - /// expressions like function or struct names - /// - /// structs will throw compile errors on the code generation so this is safe - Path(ExprPath), - /// function call like expressions - Call(ExprCall), -} - -impl HookAttributeKind { - fn parse( - input: syn::parse::ParseStream, - default_hook_path: impl FnOnce() -> ExprPath, - ) -> Result { - if input.peek(Token![=]) { - input.parse::()?; - input.parse::().and_then(Self::from_expr) - } else { - Ok(Self::Path(default_hook_path())) - } - } - - fn from_expr(value: Expr) -> Result { - match value { - Expr::Path(path) => Ok(HookAttributeKind::Path(path)), - Expr::Call(call) => Ok(HookAttributeKind::Call(call)), - // throw meaningful error on all other expressions - _ => Err(syn::Error::new( - value.span(), - [ - "Not supported in this position, please use one of the following:", - "- path to function", - "- call to function yielding closure", - ] - .join("\n"), - )), - } - } - - fn to_token_stream(&self, bevy_ecs_path: &Path) -> TokenStream2 { - match self { - HookAttributeKind::Path(path) => path.to_token_stream(), - HookAttributeKind::Call(call) => { - quote!({ - fn _internal_hook(world: #bevy_ecs_path::world::DeferredWorld, ctx: #bevy_ecs_path::lifecycle::HookContext) { - (#call)(world, ctx) - } - _internal_hook - }) - } - } - } -} - -#[derive(Debug)] -pub(super) enum MapEntitiesAttributeKind { - /// expressions like function or struct names - /// - /// structs will throw compile errors on the code generation so this is safe - Path(ExprPath), - /// When no value is specified - Default, -} - -impl MapEntitiesAttributeKind { - fn from_expr(value: Expr) -> Result { - match value { - Expr::Path(path) => Ok(Self::Path(path)), - // throw meaningful error on all other expressions - _ => Err(syn::Error::new( - value.span(), - [ - "Not supported in this position, please use one of the following:", - "- path to function", - "- nothing to default to MapEntities implementation", - ] - .join("\n"), - )), - } - } - - fn to_token_stream(&self, bevy_ecs_path: &Path) -> TokenStream2 { - match self { - MapEntitiesAttributeKind::Path(path) => path.to_token_stream(), - MapEntitiesAttributeKind::Default => { - quote!( - ::map_entities - ) - } - } - } -} - -impl Parse for MapEntitiesAttributeKind { - fn parse(input: syn::parse::ParseStream) -> Result { - if input.peek(Token![=]) { - input.parse::()?; - input.parse::().and_then(Self::from_expr) - } else { - Ok(Self::Default) - } - } -} - -struct Attrs { - storage: StorageTy, - requires: Option>, - on_add: Option, - on_insert: Option, - on_discard: Option, - on_remove: Option, - on_despawn: Option, - relationship: Option, - relationship_target: Option, - immutable: bool, - clone_behavior: Option, - map_entities: Option, - #[cfg(feature = "scene")] - scene: Option, - #[cfg(feature = "scene")] - scene_props: Option, -} - -#[derive(Clone, Copy)] -enum StorageTy { - Table, - SparseSet, -} - -struct Require { - path: Path, - func: Option, -} - -struct Relationship { - relationship_target: Type, - allow_self_referential: bool, -} - -struct RelationshipTarget { - relationship: Type, - linked_spawn: bool, -} - -// values for `storage` attribute -const TABLE: &str = "Table"; -const SPARSE_SET: &str = "SparseSet"; - -fn parse_component_attr(ast: &DeriveInput) -> Result { - let mut attrs = Attrs { - storage: StorageTy::Table, - on_add: None, - on_insert: None, - on_discard: None, - on_remove: None, - on_despawn: None, - requires: None, - relationship: None, - relationship_target: None, - immutable: false, - clone_behavior: None, - map_entities: None, - #[cfg(feature = "scene")] - scene: None, - #[cfg(feature = "scene")] - scene_props: None, - }; - - let mut require_paths = HashSet::new(); - for attr in ast.attrs.iter() { - if attr.path().is_ident(COMPONENT) { - attr.parse_nested_meta(|nested| { - if nested.path.is_ident(STORAGE) { - attrs.storage = match nested.value()?.parse::()?.value() { - s if s == TABLE => StorageTy::Table, - s if s == SPARSE_SET => StorageTy::SparseSet, - s => { - return Err(nested.error(format!( - "Invalid storage type `{s}`, expected '{TABLE}' or '{SPARSE_SET}'.", - ))); - } - }; - Ok(()) - } else if nested.path.is_ident(ON_ADD) { - attrs.on_add = Some(HookAttributeKind::parse(nested.input, || { - parse_quote! { Self::on_add } - })?); - Ok(()) - } else if nested.path.is_ident(ON_INSERT) { - attrs.on_insert = Some(HookAttributeKind::parse(nested.input, || { - parse_quote! { Self::on_insert } - })?); - Ok(()) - } else if nested.path.is_ident(ON_DISCARD) { - attrs.on_discard = Some(HookAttributeKind::parse(nested.input, || { - parse_quote! { Self::on_discard } - })?); - Ok(()) - } else if nested.path.is_ident(ON_REMOVE) { - attrs.on_remove = Some(HookAttributeKind::parse(nested.input, || { - parse_quote! { Self::on_remove } - })?); - Ok(()) - } else if nested.path.is_ident(ON_DESPAWN) { - attrs.on_despawn = Some(HookAttributeKind::parse(nested.input, || { - parse_quote! { Self::on_despawn } - })?); - Ok(()) - } else if nested.path.is_ident(IMMUTABLE) { - attrs.immutable = true; - Ok(()) - } else if nested.path.is_ident(CLONE_BEHAVIOR) { - attrs.clone_behavior = Some(nested.value()?.parse()?); - Ok(()) - } else if nested.path.is_ident(MAP_ENTITIES) { - attrs.map_entities = Some(nested.input.parse::()?); - Ok(()) - } else { - #[cfg(feature = "scene")] - { - if nested.path.is_ident("scene") { - attrs.scene = Some(nested.input.parse::()?); - return Ok(()); - } else if nested.path.is_ident("scene_props") { - nested.input.parse::()?; - attrs.scene_props = Some(nested.input.parse::()?); - return Ok(()); - } - } - Err(nested.error("Unsupported attribute")) - } - })?; - } else if attr.path().is_ident(REQUIRE) { - let punctuated = - attr.parse_args_with(Punctuated::::parse_terminated)?; - for require in punctuated.iter() { - if !require_paths.insert(require.path.to_token_stream().to_string()) { - return Err(syn::Error::new( - require.path.span(), - "Duplicate required components are not allowed.", - )); - } - } - if let Some(current) = &mut attrs.requires { - current.extend(punctuated); - } else { - attrs.requires = Some(punctuated); - } - } else if attr.path().is_ident(RELATIONSHIP) { - let relationship = attr.parse_args::()?; - attrs.relationship = Some(relationship); - } else if attr.path().is_ident(RELATIONSHIP_TARGET) { - let relationship_target = attr.parse_args::()?; - attrs.relationship_target = Some(relationship_target); - } - } - - if attrs.relationship_target.is_some() && attrs.clone_behavior.is_some() { - return Err(syn::Error::new( - attrs.clone_behavior.span(), - "A Relationship Target already has its own clone behavior, please remove `clone_behavior = ...`", - )); - } - - Ok(attrs) -} - -impl Parse for Require { - fn parse(input: syn::parse::ParseStream) -> Result { - let mut path = input.parse::()?; - let mut last_segment_is_lower = false; - let mut is_constructor_call = false; - - // Use the case of the type name to check if it's an enum - // This doesn't match everything that can be an enum according to the rust spec - // but it matches what clippy is OK with - let is_enum = { - let mut first_chars = path - .segments - .iter() - .rev() - .filter_map(|s| s.ident.to_string().chars().next()); - if let Some(last) = first_chars.next() { - if last.is_uppercase() { - if let Some(last) = first_chars.next() { - last.is_uppercase() - } else { - false - } - } else { - last_segment_is_lower = true; - false - } - } else { - false - } - }; - - let func = if input.peek(Token![=]) { - // If there is an '=', then this is a "function style" require - input.parse::()?; - let expr: Expr = input.parse()?; - Some(quote!(|| #expr )) - } else if input.peek(Brace) { - // This is a "value style" named-struct-like require - let content; - braced!(content in input); - let content = content.parse::()?; - Some(quote!(|| #path { #content })) - } else if input.peek(Paren) { - // This is a "value style" tuple-struct-like require - let content; - parenthesized!(content in input); - let content = content.parse::()?; - is_constructor_call = last_segment_is_lower; - Some(quote!(|| #path (#content))) - } else if is_enum { - // if this is an enum, then it is an inline enum component declaration - Some(quote!(|| #path)) - } else { - // if this isn't any of the above, then it is a component ident, which will use Default - None - }; - if is_enum || is_constructor_call { - path.segments.pop(); - path.segments.pop_punct(); - } - Ok(Require { path, func }) - } -} - -fn storage_path(bevy_ecs_path: &Path, ty: StorageTy) -> TokenStream2 { - let storage_type = match ty { - StorageTy::Table => Ident::new("Table", Span::call_site()), - StorageTy::SparseSet => Ident::new("SparseSet", Span::call_site()), - }; - - quote! { #bevy_ecs_path::component::StorageType::#storage_type } -} - -fn hook_register_function_call( - bevy_ecs_path: &Path, - hook: TokenStream2, - function: Option, -) -> Option { - function.map(|meta| { - quote! { - fn #hook() -> ::core::option::Option<#bevy_ecs_path::lifecycle::ComponentHook> { - ::core::option::Option::Some(#meta) - } - } - }) -} - -mod kw { - syn::custom_keyword!(relationship_target); - syn::custom_keyword!(relationship); - syn::custom_keyword!(linked_spawn); - syn::custom_keyword!(allow_self_referential); -} - -impl Parse for Relationship { - fn parse(input: syn::parse::ParseStream) -> Result { - let mut relationship_target: Option = None; - let mut allow_self_referential: bool = false; - - while !input.is_empty() { - let lookahead = input.lookahead1(); - if lookahead.peek(kw::allow_self_referential) { - input.parse::()?; - allow_self_referential = true; - } else if lookahead.peek(kw::relationship_target) { - input.parse::()?; - input.parse::()?; - relationship_target = Some(input.parse()?); - } else { - return Err(lookahead.error()); - } - if !input.is_empty() { - input.parse::()?; - } - } - Ok(Relationship { - relationship_target: relationship_target.ok_or_else(|| { - syn::Error::new(input.span(), "Missing `relationship_target = X` attribute") - })?, - allow_self_referential, - }) - } -} - -impl Parse for RelationshipTarget { - fn parse(input: syn::parse::ParseStream) -> Result { - let mut relationship: Option = None; - let mut linked_spawn: bool = false; - - while !input.is_empty() { - let lookahead = input.lookahead1(); - if lookahead.peek(kw::linked_spawn) { - input.parse::()?; - linked_spawn = true; - } else if lookahead.peek(kw::relationship) { - input.parse::()?; - input.parse::()?; - relationship = Some(input.parse()?); - } else { - return Err(lookahead.error()); - } - if !input.is_empty() { - input.parse::()?; - } - } - Ok(RelationshipTarget { - relationship: relationship.ok_or_else(|| { - syn::Error::new(input.span(), "Missing `relationship = X` attribute") - })?, - linked_spawn, - }) - } -} - -fn derive_relationship( - ast: &DeriveInput, - attrs: &Attrs, - bevy_ecs_path: &Path, -) -> Result> { - let Some(relationship) = &attrs.relationship else { - return Ok(None); - }; - let Data::Struct(DataStruct { - fields, - struct_token, - .. - }) = &ast.data - else { - return Err(syn::Error::new( - ast.span(), - "Relationship can only be derived for structs.", - )); - }; - let field = relationship_field(fields, "Relationship", struct_token.span())?; - - let relationship_member = field.ident.clone().map_or(Member::from(0), Member::Named); - let members = fields - .members() - .filter(|member| member != &relationship_member); - - let struct_name = &ast.ident; - let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); - - let relationship_target = &relationship.relationship_target; - let allow_self_referential = relationship.allow_self_referential; - - Ok(Some(quote! { - impl #impl_generics #bevy_ecs_path::relationship::Relationship for #struct_name #type_generics #where_clause { - type RelationshipTarget = #relationship_target; - const ALLOW_SELF_REFERENTIAL: bool = #allow_self_referential; - - #[inline(always)] - fn get(&self) -> #bevy_ecs_path::entity::Entity { - self.#relationship_member - } - - #[inline] - fn from(entity: #bevy_ecs_path::entity::Entity) -> Self { - Self { - #(#members: ::core::default::Default::default(),)* - #relationship_member: entity - } - } - - #[inline] - fn set_risky(&mut self, entity: #bevy_ecs_path::entity::Entity) { - self.#relationship_member = entity; - } - } - })) -} - -fn derive_relationship_target( - ast: &DeriveInput, - attrs: &Attrs, - bevy_ecs_path: &Path, -) -> Result> { - let Some(relationship_target) = &attrs.relationship_target else { - return Ok(None); - }; - - let Data::Struct(DataStruct { - fields, - struct_token, - .. - }) = &ast.data - else { - return Err(syn::Error::new( - ast.span(), - "RelationshipTarget can only be derived for structs.", - )); - }; - let field = relationship_field(fields, "RelationshipTarget", struct_token.span())?; - - if field.vis != Visibility::Inherited { - return Err(syn::Error::new(field.span(), "The collection in RelationshipTarget must be private to prevent users from directly mutating it, which could invalidate the correctness of relationships.")); - } - let collection = &field.ty; - let relationship_member = field.ident.clone().map_or(Member::from(0), Member::Named); - - let members = fields - .members() - .filter(|member| member != &relationship_member); - - let relationship = &relationship_target.relationship; - let struct_name = &ast.ident; - let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); - let linked_spawn = relationship_target.linked_spawn; - Ok(Some(quote! { - impl #impl_generics #bevy_ecs_path::relationship::RelationshipTarget for #struct_name #type_generics #where_clause { - const LINKED_SPAWN: bool = #linked_spawn; - type Relationship = #relationship; - type Collection = #collection; - - #[inline] - fn collection(&self) -> &Self::Collection { - &self.#relationship_member - } - - #[inline] - fn collection_mut_risky(&mut self) -> &mut Self::Collection { - &mut self.#relationship_member - } - - #[inline] - fn from_collection_risky(collection: Self::Collection) -> Self { - Self { - #(#members: ::core::default::Default::default(),)* - #relationship_member: collection - } - } - } - })) -} - -/// Returns the field with the `#[relationship]` attribute, the only field if unnamed, -/// or the only field in a [`Fields::Named`] with one field, otherwise `Err`. -fn relationship_field<'a>( - fields: &'a Fields, - derive: &'static str, - span: Span, -) -> Result<&'a Field> { - match fields { - Fields::Named(fields) if fields.named.len() == 1 => Ok(fields.named.first().unwrap()), - Fields::Named(fields) => fields.named.iter().find(|field| { - field - .attrs - .iter() - .any(|attr| attr.path().is_ident(RELATIONSHIP)) - }).ok_or(syn::Error::new( - span, - format!("{derive} derive expected named structs with a single field or with a field annotated with #[relationship].") - )), - Fields::Unnamed(fields) if fields.unnamed.len() == 1 => Ok(fields.unnamed.first().unwrap()), - Fields::Unnamed(fields) => fields.unnamed.iter().find(|field| { - field - .attrs - .iter() - .any(|attr| attr.path().is_ident(RELATIONSHIP)) - }) - .ok_or(syn::Error::new( - span, - format!("{derive} derive expected unnamed structs with one field or with a field annotated with #[relationship]."), - )), - Fields::Unit => Err(syn::Error::new( - span, - format!("{derive} derive expected named or unnamed struct, found unit struct."), - )), - } -} diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index 5b240277023b2..a5cd0e8fc116f 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -4,21 +4,17 @@ extern crate proc_macro; -mod component; mod event; mod message; mod query_data; mod query_filter; -#[cfg(feature = "scene")] -mod scene; +mod resource; mod template; mod variant_defaults; mod world_query; -use crate::{ - component::map_entities, query_data::derive_query_data_impl, - query_filter::derive_query_filter_impl, -}; +use crate::{query_data::derive_query_data_impl, query_filter::derive_query_filter_impl}; +use bevy_ecs_macro_logic::{component::DeriveComponent, map_entities::map_entities}; use bevy_macro_utils::{ derive_label, ensure_no_collision, get_struct_fields, pascal_to_snake_case, BevyManifest, }; @@ -563,7 +559,8 @@ pub fn derive_message(input: TokenStream) -> TokenStream { /// Implement the `Resource` trait. #[proc_macro_derive(Resource)] pub fn derive_resource(input: TokenStream) -> TokenStream { - component::derive_resource(input) + let mut ast = parse_macro_input!(input as DeriveInput); + TokenStream::from(resource::derive_resource(&mut ast)) } /// Cheat sheet for derive syntax, @@ -804,7 +801,13 @@ pub fn derive_settings_group(input: TokenStream) -> TokenStream { attributes(component, require, relationship, relationship_target, entities) )] pub fn derive_component(input: TokenStream) -> TokenStream { - component::derive_component(input) + let mut ast = parse_macro_input!(input as DeriveInput); + let derive_component = match DeriveComponent::parse(&ast) { + Ok(value) => value, + Err(e) => return e.into_compile_error().into(), + }; + let bevy_ecs = bevy_ecs_path(); + TokenStream::from(derive_component.impl_component(&mut ast, &bevy_ecs)) } /// Implement the `FromWorld` trait. diff --git a/crates/bevy_ecs/macros/src/resource.rs b/crates/bevy_ecs/macros/src/resource.rs new file mode 100644 index 0000000000000..c85ac6b7802f5 --- /dev/null +++ b/crates/bevy_ecs/macros/src/resource.rs @@ -0,0 +1,36 @@ +use bevy_ecs_macro_logic::component::DeriveComponent; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{DeriveInput, Path}; + +pub fn derive_resource(ast: &mut DeriveInput) -> TokenStream { + let bevy_ecs: Path = crate::bevy_ecs_path(); + let mut derive_component = match DeriveComponent::parse(ast) { + Ok(value) => value, + Err(e) => return e.into_compile_error(), + }; + + let struct_name = &ast.ident; + let (_, type_generics, _) = &ast.generics.split_for_impl(); + + // We add the component_id existence check here to avoid recursive init during required components initialization. + derive_component.additional_requires.push(quote! { + let resource_component_id = if let ::core::option::Option::Some(id) = required_components.components_registrator().component_id::<#struct_name #type_generics>() { + id + } else { + required_components.components_registrator().register_component::<#struct_name #type_generics>() + }; + required_components.register_required::<#bevy_ecs::resource::IsResource>(move || #bevy_ecs::resource::IsResource::new(resource_component_id)); + }); + + let component_impl = derive_component.impl_component(ast, &bevy_ecs); + + let struct_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); + + quote! { + #component_impl + impl #impl_generics #bevy_ecs::resource::Resource for #struct_name #type_generics #where_clause { + } + } +} diff --git a/crates/bevy_ecs/macros/src/scene.rs b/crates/bevy_ecs/macros/src/scene.rs deleted file mode 100644 index 2a5d9f07fb2dd..0000000000000 --- a/crates/bevy_ecs/macros/src/scene.rs +++ /dev/null @@ -1,72 +0,0 @@ -use proc_macro2::TokenStream; -use quote::quote; -use syn::{ - parse::{Parse, ParseStream}, - parse_str, DeriveInput, LitStr, Path, Token, -}; - -/// Parsed scene information -pub(crate) enum Scene { - Function(Path), - Asset(LitStr), -} - -impl Scene { - pub(crate) fn default_scene_function() -> Self { - // Self::scene will always parse correctly - Scene::Function(parse_str::("Self::scene").unwrap()) - } -} - -impl Parse for Scene { - fn parse(input: ParseStream) -> syn::Result { - Ok(if input.peek(Token![=]) { - input.parse::()?; - if input.peek(LitStr) { - Scene::Asset(input.parse::()?) - } else { - Scene::Function(input.parse::()?) - } - } else { - Scene::default_scene_function() - }) - } -} - -pub(crate) fn derive_scene_constructor( - ast: &DeriveInput, - bevy_ecs: &Path, - bevy_scene: &Path, - scene: Scene, - scene_props: Option, -) -> TokenStream { - let struct_name = &ast.ident; - let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); - - let scene_impl = match scene { - Scene::Function(path) => { - if scene_props.is_some() { - quote! {#path(props)} - } else { - quote! {#path()} - } - } - Scene::Asset(lit_str) => quote! {#bevy_scene::InheritSceneAsset::from(#lit_str)}, - }; - let props_type = match scene_props { - Some(props) => quote! {#props}, - None => quote! {()}, - }; - quote! { - impl #impl_generics #bevy_scene::SceneConstructor for #struct_name #type_generics #where_clause { - type Props = #props_type; - fn scene(props: Self::Props) -> impl Scene { - ( - #scene_impl, - #bevy_scene::InitTemplate::<<#struct_name #type_generics as #bevy_ecs::template::FromTemplate>::Template>::default(), - #bevy_scene::template_value(#bevy_scene::SceneComponentInfo::new::<#struct_name #type_generics>(true)), - ) - } - } - } -} diff --git a/crates/bevy_ecs/src/template.rs b/crates/bevy_ecs/src/template.rs index 65e69ab58d895..df55958f0b7ef 100644 --- a/crates/bevy_ecs/src/template.rs +++ b/crates/bevy_ecs/src/template.rs @@ -333,7 +333,7 @@ impl ScopedEntities { /// ``` pub trait FromTemplate: Sized { /// The [`Template`] for this type. - type Template: Template; + type Template: Template; } macro_rules! template_impl { diff --git a/crates/bevy_feathers/src/controls/button.rs b/crates/bevy_feathers/src/controls/button.rs index 007e4c10038ee..0b2caf7c1b87e 100644 --- a/crates/bevy_feathers/src/controls/button.rs +++ b/crates/bevy_feathers/src/controls/button.rs @@ -14,7 +14,7 @@ use bevy_ecs::{ use bevy_input_focus::tab_navigation::TabIndex; use bevy_picking::{hover::Hovered, PickingSystems}; use bevy_reflect::{prelude::ReflectDefault, Reflect}; -use bevy_scene::{prelude::*, template_value}; +use bevy_scene::prelude::*; use bevy_text::FontWeight; use bevy_ui::{AlignItems, InteractionDisabled, JustifyContent, Node, Pressed, UiRect, Val}; use bevy_ui_widgets::Button; @@ -54,8 +54,8 @@ pub enum ButtonVariant { /// * the ENTER or SPACE key is pressed while the button has keyboard focus. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -#[derive(Component, Default, Clone)] -#[component(scene_props = FeathersButtonProps)] +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersButtonProps)] pub struct FeathersButton; /// Props used to construct a [`FeathersButton`] scene. @@ -119,8 +119,8 @@ impl FeathersButton { /// * the ENTER or SPACE key is pressed while the button has keyboard focus. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -#[derive(Component, Default, Clone)] -#[component(scene_props = FeathersButtonProps)] +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersButtonProps)] pub struct ToolButton; impl ToolButton { diff --git a/crates/bevy_feathers/src/controls/checkbox.rs b/crates/bevy_feathers/src/controls/checkbox.rs index bbcab091af9d2..a541141b1845f 100644 --- a/crates/bevy_feathers/src/controls/checkbox.rs +++ b/crates/bevy_feathers/src/controls/checkbox.rs @@ -43,8 +43,8 @@ use crate::{ /// * [`bevy_ui_widgets::ValueChange`] with the new value when the checkbox changes state. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -#[derive(Component, FromTemplate)] -#[component(scene_props = FeathersCheckboxProps)] +#[derive(SceneComponent, FromTemplate)] +#[scene(FeathersCheckboxProps)] pub struct FeathersCheckbox; /// Props used to construct a [`FeathersCheckbox`] scene. diff --git a/crates/bevy_feathers/src/controls/color_plane.rs b/crates/bevy_feathers/src/controls/color_plane.rs index 2b12f91619e47..8f3b8ff5f0053 100644 --- a/crates/bevy_feathers/src/controls/color_plane.rs +++ b/crates/bevy_feathers/src/controls/color_plane.rs @@ -43,8 +43,9 @@ use crate::{cursor::EntityCursor, palette, theme::ThemeBackgroundColor, tokens}; /// The control does not do any color space conversions internally, other than the shader code /// for displaying gradients. Avoiding excess conversions helps avoid gimble-lock problems when /// implementing a color picker for cylindrical color spaces such as HSL. -#[derive(Component, FromTemplate, Debug, Reflect, Copy, PartialEq, Eq, Hash, Default, Clone)] -#[component(scene)] +#[derive( + SceneComponent, FromTemplate, Debug, Reflect, Copy, PartialEq, Eq, Hash, Default, Clone, +)] #[reflect(Component)] #[require(ColorPlaneDragState)] pub enum FeathersColorPlane { diff --git a/crates/bevy_feathers/src/controls/color_slider.rs b/crates/bevy_feathers/src/controls/color_slider.rs index 0fcff5c1acc95..d86e1f922ee1b 100644 --- a/crates/bevy_feathers/src/controls/color_slider.rs +++ b/crates/bevy_feathers/src/controls/color_slider.rs @@ -16,7 +16,7 @@ use bevy_ecs::{ use bevy_input_focus::tab_navigation::TabIndex; use bevy_log::warn_once; use bevy_picking::PickingSystems; -use bevy_scene::{prelude::*, template_value}; +use bevy_scene::prelude::*; use bevy_ui::{ AlignItems, BackgroundColor, BackgroundGradient, BorderColor, BorderRadius, ColorStop, Display, FlexDirection, Gradient, InterpolationColorSpace, LinearGradient, Node, Outline, PositionType, @@ -152,8 +152,8 @@ pub struct SliderBaseColor(pub Color); /// * [`bevy_ui_widgets::ValueChange`] when the slider value is changed. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -#[derive(Component, Default, Clone)] -#[component(scene_props = FeathersColorSliderProps)] +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersColorSliderProps)] pub struct FeathersColorSlider; /// Props used to construct a [`FeathersColorSlider`] scene. diff --git a/crates/bevy_feathers/src/controls/color_swatch.rs b/crates/bevy_feathers/src/controls/color_swatch.rs index d60982df23d4f..d44d1a0cd2eca 100644 --- a/crates/bevy_feathers/src/controls/color_swatch.rs +++ b/crates/bevy_feathers/src/controls/color_swatch.rs @@ -24,8 +24,7 @@ use crate::{ /// A color swatch widget. /// /// This is spawnable by inheriting it as a "scene component". -#[derive(Component, Default, Clone, Reflect)] -#[component(scene)] +#[derive(SceneComponent, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] pub struct FeathersColorSwatch; diff --git a/crates/bevy_feathers/src/controls/disclosure_toggle.rs b/crates/bevy_feathers/src/controls/disclosure_toggle.rs index 416b8ca9016f8..16634e1c35894 100644 --- a/crates/bevy_feathers/src/controls/disclosure_toggle.rs +++ b/crates/bevy_feathers/src/controls/disclosure_toggle.rs @@ -1,6 +1,5 @@ use bevy_app::{App, Plugin, PreUpdate}; use bevy_ecs::{ - component::Component, hierarchy::Children, lifecycle::RemovedComponents, query::{Added, Has, Or, With}, @@ -10,7 +9,7 @@ use bevy_ecs::{ use bevy_input_focus::tab_navigation::TabIndex; use bevy_math::Rot2; use bevy_picking::PickingSystems; -use bevy_scene::{bsn, Scene}; +use bevy_scene::{bsn, Scene, SceneComponent}; use bevy_ui::{ px, widget::ImageNode, AlignItems, Checked, Display, InteractionDisabled, JustifyContent, Node, UiTransform, @@ -28,8 +27,7 @@ use crate::{ /// state. /// /// This is spawnable by inheriting it as a "scene component". -#[derive(Component, Default, Clone)] -#[component(scene)] +#[derive(SceneComponent, Default, Clone)] pub struct FeathersDisclosureToggle; impl FeathersDisclosureToggle { diff --git a/crates/bevy_feathers/src/controls/menu.rs b/crates/bevy_feathers/src/controls/menu.rs index b1aa833a3d585..add6480878cdb 100644 --- a/crates/bevy_feathers/src/controls/menu.rs +++ b/crates/bevy_feathers/src/controls/menu.rs @@ -3,7 +3,6 @@ use bevy_camera::visibility::Visibility; use bevy_color::{Alpha, Srgba}; use bevy_ecs::{ change_detection::DetectChanges, - component::Component, entity::Entity, hierarchy::Children, lifecycle::RemovedComponents, @@ -43,8 +42,7 @@ use bevy_input_focus::{ /// Top-level menu container. This wraps the menu button and provides an anchor for the popover. /// /// This is spawnable by inheriting it as a "scene component". -#[derive(Component, Clone, Default)] -#[component(scene)] +#[derive(SceneComponent, Clone, Default)] pub struct FeathersMenu; impl FeathersMenu { @@ -134,8 +132,8 @@ fn on_menu_event( /// A menu button widget. This produces a button that has a dropdown arrow. /// /// This is spawnable by inheriting it as a "scene component" with optional [`FeathersMenuButtonProps`]. -#[derive(Component, Default, Clone)] -#[component(scene_props = FeathersMenuButtonProps)] +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersMenuButtonProps)] pub struct FeathersMenuButton; /// Props used to construct a [`FeathersMenuButton`] scene. @@ -188,8 +186,7 @@ impl FeathersMenuButton { } /// A menu popup widget. -#[derive(Component, Default, Clone)] -#[component(scene)] +#[derive(SceneComponent, Default, Clone)] pub struct FeathersMenuPopup; impl FeathersMenuPopup { @@ -243,8 +240,8 @@ impl FeathersMenuPopup { /// A menu item widget. /// /// This is spawnable by inheriting it as a "scene component" with optional [`FeathersMenuItemProps`]. -#[derive(Component, Default, Clone)] -#[component(scene_props = FeathersMenuItemProps )] +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersMenuItemProps)] pub struct FeathersMenuItem; /// Props used to construct a [`FeathersMenuItem`] scene. @@ -432,8 +429,7 @@ fn set_menuitem_colors( } /// A decorative divider between menu items -#[derive(Component, Default, Clone)] -#[component(scene)] +#[derive(SceneComponent, Default, Clone)] pub struct FeathersMenuDivider; impl FeathersMenuDivider { diff --git a/crates/bevy_feathers/src/controls/number_input.rs b/crates/bevy_feathers/src/controls/number_input.rs index 9806967a3d022..4c1298984fb2e 100644 --- a/crates/bevy_feathers/src/controls/number_input.rs +++ b/crates/bevy_feathers/src/controls/number_input.rs @@ -48,8 +48,8 @@ use crate::{ /// because of some other action, trigger an [`UpdateNumberInput`] entity event to update the /// displayed value. // TODO: Add text_input field validation when it becomes available. -#[derive(Component, Default, Clone)] -#[component(scene_props = FeathersNumberInputProps)] +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersNumberInputProps)] pub struct FeathersNumberInput; /// Props used to construct a [`FeathersNumberInput`] scene. diff --git a/crates/bevy_feathers/src/controls/radio.rs b/crates/bevy_feathers/src/controls/radio.rs index 48e6f6bd437c2..b780e5d417d8c 100644 --- a/crates/bevy_feathers/src/controls/radio.rs +++ b/crates/bevy_feathers/src/controls/radio.rs @@ -42,8 +42,8 @@ use crate::{ /// * [`bevy_ui_widgets::ValueChange`] with the selected entity's id when a new radio button is selected. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -#[derive(Component, Default, Clone)] -#[component(scene_props = FeathersRadioProps)] +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersRadioProps)] pub struct FeathersRadio; /// Props used to construct a [`FeathersRadio`] scene. diff --git a/crates/bevy_feathers/src/controls/slider.rs b/crates/bevy_feathers/src/controls/slider.rs index ecb34d5b31e9a..16ee97e5523b3 100644 --- a/crates/bevy_feathers/src/controls/slider.rs +++ b/crates/bevy_feathers/src/controls/slider.rs @@ -47,8 +47,8 @@ use crate::{ /// * [`bevy_ui_widgets::ValueChange`] when the slider value is changed. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -#[derive(Component, Default, Clone, Reflect)] -#[component(scene_props = FeathersSliderProps)] +#[derive(SceneComponent, Default, Clone, Reflect)] +#[scene(FeathersSliderProps)] #[require(Slider)] #[reflect(Component, Clone, Default)] pub struct FeathersSlider; diff --git a/crates/bevy_feathers/src/controls/text_input.rs b/crates/bevy_feathers/src/controls/text_input.rs index 105cde27303f8..d937fb5021723 100644 --- a/crates/bevy_feathers/src/controls/text_input.rs +++ b/crates/bevy_feathers/src/controls/text_input.rs @@ -2,7 +2,6 @@ use bevy_app::{Plugin, PreUpdate, PropagateOver}; use bevy_asset::AssetServer; use bevy_ecs::{ change_detection::DetectChanges, - component::Component, entity::Entity, lifecycle::RemovedComponents, query::{Added, Has, With}, @@ -33,8 +32,7 @@ use crate::{ /// (such as "search" or "clear") to be inserted adjacent to the input. /// /// This is spawnable by inheriting it as a "scene component". -#[derive(Component, Default, Clone)] -#[component(scene)] +#[derive(SceneComponent, Default, Clone)] pub struct FeathersTextInputContainer; impl FeathersTextInputContainer { @@ -78,8 +76,8 @@ impl FeathersTextInputContainer { /// :FeathersTextInput /// ] /// ``` -#[derive(Component, Default, Clone)] -#[component(scene_props = FeathersTextInputProps)] +#[derive(SceneComponent, Default, Clone)] +#[scene(FeathersTextInputProps)] pub struct FeathersTextInput; /// Props used to construct the [`FeathersTextInput`] scene. diff --git a/crates/bevy_feathers/src/controls/toggle_switch.rs b/crates/bevy_feathers/src/controls/toggle_switch.rs index 54d4d48af6e6c..8f0c19d3debd9 100644 --- a/crates/bevy_feathers/src/controls/toggle_switch.rs +++ b/crates/bevy_feathers/src/controls/toggle_switch.rs @@ -39,8 +39,7 @@ use crate::{ /// * [`bevy_ui_widgets::ValueChange`] with the new value when the toggle switch changes state. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the bundle -#[derive(Component, Default, Clone, Reflect)] -#[component(scene)] +#[derive(SceneComponent, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] pub struct FeathersToggleSwitch; diff --git a/crates/bevy_feathers/src/controls/virtual_keyboard.rs b/crates/bevy_feathers/src/controls/virtual_keyboard.rs index 429f1e387ccbe..eb8f434705019 100644 --- a/crates/bevy_feathers/src/controls/virtual_keyboard.rs +++ b/crates/bevy_feathers/src/controls/virtual_keyboard.rs @@ -20,8 +20,8 @@ use crate::controls::FeathersButton; /// * [`crate::controls::VirtualKeyPressed`] when a virtual key on the keyboard is un-pressed. /// /// These events can be disabled by adding an [`bevy_ui::InteractionDisabled`] component to the entity -#[derive(Component, FromTemplate)] -#[component(scene_props = VirtualKeyboardProps)] +#[derive(SceneComponent, FromTemplate)] +#[scene(VirtualKeyboardProps)] pub struct VirtualKeyboard + Clone + Send + Sync + 'static>(PhantomData T>); /// Props used to construct the [`VirtualKeyboard`] scene. diff --git a/crates/bevy_macro_utils/src/lib.rs b/crates/bevy_macro_utils/src/lib.rs index b4dcb9c3b4541..a6324195c0d85 100644 --- a/crates/bevy_macro_utils/src/lib.rs +++ b/crates/bevy_macro_utils/src/lib.rs @@ -17,6 +17,7 @@ mod label; mod member; mod parser; mod path; +mod path_type; mod result_sifter; mod shape; mod symbol; @@ -27,6 +28,7 @@ pub use label::*; pub use member::*; pub use parser::*; pub use path::*; +pub use path_type::*; pub use result_sifter::*; pub use shape::*; pub use symbol::*; diff --git a/crates/bevy_macro_utils/src/path_type.rs b/crates/bevy_macro_utils/src/path_type.rs new file mode 100644 index 0000000000000..983263a524497 --- /dev/null +++ b/crates/bevy_macro_utils/src/path_type.rs @@ -0,0 +1,195 @@ +use syn::Path; + +/// The type of a [`Path`], using standard Rust naming conventions to infer the type. +/// Note that this only works for paths that actually follow the naming conventions, and there are +/// inherent ambiguities, such as `Some` either referring to a type or an enum variant. +#[derive(PartialEq, Eq, Debug)] +pub enum PathType { + /// Path that follows Rust type conventions. + Type, + /// Path that follows Rust enum conventions. + Enum, + /// Path that follows Rust const conventions. + Const, + /// Path that follows Rust type-associated const conventions. + TypeConst, + /// Path that follows Rust type-associated function conventions. + TypeFunction, + /// Path that follows Rust function conventions. + Function, +} + +impl PathType { + /// Determines the [`PathType`] for the given path. + pub fn new(path: &Path) -> PathType { + let mut iter = path.segments.iter().rev(); + if let Some(last_segment) = iter.next() { + let last_string = last_segment.ident.to_string(); + let mut last_string_chars = last_string.chars(); + let last_ident_first_char = last_string_chars.next().unwrap(); + if last_ident_first_char.is_uppercase() { + let is_const = is_const(&last_string); + if let Some(second_to_last_segment) = iter.next() { + // PERF: is there some way to avoid this string allocation? + let second_to_last_string = second_to_last_segment.ident.to_string(); + let first_char = second_to_last_string.chars().next().unwrap(); + if first_char.is_uppercase() { + if is_const { + PathType::TypeConst + } else { + PathType::Enum + } + } else if is_const { + PathType::Const + } else { + PathType::Type + } + } else if is_const { + PathType::Const + } else { + PathType::Type + } + } else if let Some(second_to_last) = iter.next() { + // PERF: is there some way to avoid this string allocation? + let second_to_last_string = second_to_last.ident.to_string(); + let first_char = second_to_last_string.chars().next().unwrap(); + if first_char.is_uppercase() { + PathType::TypeFunction + } else { + PathType::Function + } + } else { + PathType::Function + } + } else { + // This won't be hit so just pick one to make it easy on consumers + PathType::Type + } + } +} + +fn is_const(path: &str) -> bool { + // Paths of length 1 are ambiguous, we give the tie to Types, + // as that is more useful for scenes + if path.len() == 1 { + return false; + } + + // All characters are uppercase ... this is a Const + !path.chars().any(char::is_lowercase) +} + +#[cfg(test)] +mod tests { + use super::{is_const, PathType}; + use syn::{parse_str, Path}; + + macro_rules! test_path_type { + ($test_name:ident, $input:expr, $expected:expr) => { + #[test] + fn $test_name() { + // Arrange + let path = parse_str::($input).unwrap(); + let expected = $expected; + + // Act + let result = PathType::new(&path); + + // Assert + assert_eq!(result, expected, "Failed on path: '{}'", $input); + } + }; + } + + // Types + test_path_type!(path_type_standard_root, "XType", PathType::Type); + test_path_type!(path_type_standard_namespace, "foo::XType", PathType::Type); + + // These cases are ambiguous. We parse it as a Type as that works better in the scene patching context. + test_path_type!(path_type_ambiguous_single_char_root, "X", PathType::Type); + test_path_type!( + path_type_ambiguous_single_char_namespace, + "foo::X", + PathType::Type + ); + + // Constants + test_path_type!(path_type_const_root, "X_AXIS", PathType::Const); + test_path_type!(path_type_const_namespace, "foo::X_AXIS", PathType::Const); + test_path_type!(path_type_const_no_underscore_root, "XAXIS", PathType::Const); + test_path_type!( + path_type_const_no_underscore_namespace, + "foo::XAXIS", + PathType::Const + ); + + // Enums + test_path_type!(path_type_enum_standard, "Foo::Bar", PathType::Enum); + test_path_type!(path_type_enum_namespace, "foo::Foo::Bar", PathType::Enum); + + // This is ambiguous with TypeConst ... we give the tie to Enum as that works better in a scene context. + test_path_type!( + path_type_enum_ambiguous_single_char, + "Foo::B", + PathType::Enum + ); + + // Type Functions + test_path_type!( + path_type_type_function_standard, + "Foo::bar", + PathType::TypeFunction + ); + test_path_type!( + path_type_type_function_namespace, + "foo::Foo::bar", + PathType::TypeFunction + ); + + // Type Constants + test_path_type!( + path_type_type_const_standard, + "Foo::BAR", + PathType::TypeConst + ); + + // Functions + test_path_type!(path_type_function_root, "foo", PathType::Function); + test_path_type!(path_type_function_namespace, "foo::foo", PathType::Function); + test_path_type!(path_type_function_single_char, "f", PathType::Function); + + macro_rules! test_is_const { + ($test_name:ident, $input:expr, $expected:expr) => { + #[test] + fn $test_name() { + // Arrange + let input = $input; + let expected = $expected; + + // Act + let result = is_const(input); + + // Assert + assert_eq!(result, expected, "Failed on input: '{}'", input); + } + }; + } + + // Length == 1 + test_is_const!(single_upper_is_not_const, "X", false); + test_is_const!(single_lower_is_not_const, "a", false); + + // Valid + test_is_const!(standard_const_with_underscore, "X_AXIS", true); + test_is_const!(standard_const_max_value, "MAX_VALUE", true); + test_is_const!(multiple_upper_no_underscore, "PI", true); + + // Mixed casing + test_is_const!(mixed_case_with_underscore_fails, "FOO_bar", false); + test_is_const!(short_mixed_case_with_underscore_fails, "A_b", false); + + // Types & Functions + test_is_const!(pascal_case_is_not_const, "Transform", false); + test_is_const!(snake_case_is_not_const, "my_function", false); + test_is_const!(camel_case_is_not_const, "camelCase", false); +} diff --git a/crates/bevy_scene/Cargo.toml b/crates/bevy_scene/Cargo.toml index bd086f6450a77..4f5143d00949d 100644 --- a/crates/bevy_scene/Cargo.toml +++ b/crates/bevy_scene/Cargo.toml @@ -14,9 +14,7 @@ bevy_scene_macros = { path = "macros", version = "0.19.0-dev" } bevy_app = { path = "../bevy_app", version = "0.19.0-dev" } bevy_asset = { path = "../bevy_asset", version = "0.19.0-dev" } bevy_derive = { path = "../bevy_derive", version = "0.19.0-dev" } -bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev", features = [ - "derive_scene", -] } +bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } bevy_log = { path = "../bevy_log", version = "0.19.0-dev" } bevy_platform = { path = "../bevy_platform", version = "0.19.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.19.0-dev" } diff --git a/crates/bevy_scene/macros/Cargo.toml b/crates/bevy_scene/macros/Cargo.toml index 6f9b1bdd12984..3a7d9349f8102 100644 --- a/crates/bevy_scene/macros/Cargo.toml +++ b/crates/bevy_scene/macros/Cargo.toml @@ -13,6 +13,8 @@ proc-macro = true [dependencies] bevy_macro_utils = { path = "../../bevy_macro_utils", version = "0.19.0-dev" } +bevy_ecs_macro_logic = { path = "../../bevy_ecs/macro_logic", version = "0.19.0-dev" } + syn = { version = "2.0", features = ["full", "extra-traits"] } proc-macro2 = "1.0" quote = "1.0" diff --git a/crates/bevy_scene/macros/src/bsn/codegen.rs b/crates/bevy_scene/macros/src/bsn/codegen.rs index fc51d09f7e2b0..0c5bffd4df3a1 100644 --- a/crates/bevy_scene/macros/src/bsn/codegen.rs +++ b/crates/bevy_scene/macros/src/bsn/codegen.rs @@ -259,10 +259,10 @@ impl BsnEntry { let type_path = &bsn_type.path; let from_template_patch = from_template_patch(ctx, bsn_type, true)?; quote! {{ - let mut #props = <<#type_path as #bevy_scene::SceneConstructor>::Props as Default>::default(); + let mut #props = <<#type_path as #bevy_scene::SceneComponent>::Props as Default>::default(); let #props_ref = &mut #props; #(#assignments)* - (<#type_path as #bevy_scene::SceneConstructor>::scene(#props), #from_template_patch) + (<#type_path as #bevy_scene::SceneComponent>::scene(#props), #from_template_patch) }} } BsnInheritedScene::Expression(tokens) => quote! { diff --git a/crates/bevy_scene/macros/src/bsn/parse.rs b/crates/bevy_scene/macros/src/bsn/parse.rs index d1995dc56447c..f137b253685a5 100644 --- a/crates/bevy_scene/macros/src/bsn/parse.rs +++ b/crates/bevy_scene/macros/src/bsn/parse.rs @@ -3,7 +3,7 @@ use crate::bsn::types::{ BsnRelatedSceneList, BsnRoot, BsnSceneList, BsnSceneListItem, BsnSceneListItems, BsnTuple, BsnType, BsnUnnamedField, BsnValue, }; -use bevy_macro_utils::path_to_string; +use bevy_macro_utils::{path_to_string, PathType}; use proc_macro2::{Delimiter, TokenStream, TokenTree}; use quote::quote; use syn::{ @@ -445,193 +445,8 @@ impl Parse for BsnValue { } } -#[derive(PartialEq, Eq, Debug)] -enum PathType { - Type, - Enum, - Const, - TypeConst, - TypeFunction, - Function, -} - -impl PathType { - fn new(path: &Path) -> PathType { - let mut iter = path.segments.iter().rev(); - if let Some(last_segment) = iter.next() { - let last_string = last_segment.ident.to_string(); - let mut last_string_chars = last_string.chars(); - let last_ident_first_char = last_string_chars.next().unwrap(); - if last_ident_first_char.is_uppercase() { - let is_const = is_const(&last_string); - if let Some(second_to_last_segment) = iter.next() { - // PERF: is there some way to avoid this string allocation? - let second_to_last_string = second_to_last_segment.ident.to_string(); - let first_char = second_to_last_string.chars().next().unwrap(); - if first_char.is_uppercase() { - if is_const { - PathType::TypeConst - } else { - PathType::Enum - } - } else if is_const { - PathType::Const - } else { - PathType::Type - } - } else if is_const { - PathType::Const - } else { - PathType::Type - } - } else if let Some(second_to_last) = iter.next() { - // PERF: is there some way to avoid this string allocation? - let second_to_last_string = second_to_last.ident.to_string(); - let first_char = second_to_last_string.chars().next().unwrap(); - if first_char.is_uppercase() { - PathType::TypeFunction - } else { - PathType::Function - } - } else { - PathType::Function - } - } else { - // This won't be hit so just pick one to make it easy on consumers - PathType::Type - } - } -} - -fn is_const(path: &str) -> bool { - // Paths of length 1 are ambiguous, we give the tie to Types, - // as that is more useful for scenes - if path.len() == 1 { - return false; - } - - // All characters are uppercase ... this is a Const - !path.chars().any(|c| c.is_lowercase()) -} - fn take_last_path_ident(path: &mut Path) -> Option { let ident = path.segments.pop().map(|s| s.into_value().ident); path.segments.pop_punct(); ident } - -#[cfg(test)] -mod tests { - use super::is_const; - use crate::bsn::parse::PathType; - use syn::{parse_str, Path}; - - macro_rules! test_path_type { - ($test_name:ident, $input:expr, $expected:expr) => { - #[test] - fn $test_name() { - // Arrange - let path = parse_str::($input).unwrap(); - let expected = $expected; - - // Act - let result = PathType::new(&path); - - // Assert - assert_eq!(result, expected, "Failed on path: '{}'", $input); - } - }; - } - - // Types - test_path_type!(path_type_standard_root, "XType", PathType::Type); - test_path_type!(path_type_standard_namespace, "foo::XType", PathType::Type); - - // These cases are ambiguous. We parse it as a Type as that works better in the scene patching context. - test_path_type!(path_type_ambiguous_single_char_root, "X", PathType::Type); - test_path_type!( - path_type_ambiguous_single_char_namespace, - "foo::X", - PathType::Type - ); - - // Constants - test_path_type!(path_type_const_root, "X_AXIS", PathType::Const); - test_path_type!(path_type_const_namespace, "foo::X_AXIS", PathType::Const); - test_path_type!(path_type_const_no_underscore_root, "XAXIS", PathType::Const); - test_path_type!( - path_type_const_no_underscore_namespace, - "foo::XAXIS", - PathType::Const - ); - - // Enums - test_path_type!(path_type_enum_standard, "Foo::Bar", PathType::Enum); - test_path_type!(path_type_enum_namespace, "foo::Foo::Bar", PathType::Enum); - - // This is ambiguous with TypeConst ... we give the tie to Enum as that works better in a scene context. - test_path_type!( - path_type_enum_ambiguous_single_char, - "Foo::B", - PathType::Enum - ); - - // Type Functions - test_path_type!( - path_type_type_function_standard, - "Foo::bar", - PathType::TypeFunction - ); - test_path_type!( - path_type_type_function_namespace, - "foo::Foo::bar", - PathType::TypeFunction - ); - - // Type Constants - test_path_type!( - path_type_type_const_standard, - "Foo::BAR", - PathType::TypeConst - ); - - // Functions - test_path_type!(path_type_function_root, "foo", PathType::Function); - test_path_type!(path_type_function_namespace, "foo::foo", PathType::Function); - test_path_type!(path_type_function_single_char, "f", PathType::Function); - - macro_rules! test_is_const { - ($test_name:ident, $input:expr, $expected:expr) => { - #[test] - fn $test_name() { - // Arrange - let input = $input; - let expected = $expected; - - // Act - let result = is_const(input); - - // Assert - assert_eq!(result, expected, "Failed on input: '{}'", input); - } - }; - } - - // Length == 1 - test_is_const!(single_upper_is_not_const, "X", false); - test_is_const!(single_lower_is_not_const, "a", false); - - // Valid - test_is_const!(standard_const_with_underscore, "X_AXIS", true); - test_is_const!(standard_const_max_value, "MAX_VALUE", true); - test_is_const!(multiple_upper_no_underscore, "PI", true); - - // Mixed casing - test_is_const!(mixed_case_with_underscore_fails, "FOO_bar", false); - test_is_const!(short_mixed_case_with_underscore_fails, "A_b", false); - - // Types & Functions - test_is_const!(pascal_case_is_not_const, "Transform", false); - test_is_const!(snake_case_is_not_const, "my_function", false); - test_is_const!(camel_case_is_not_const, "camelCase", false); -} diff --git a/crates/bevy_scene/macros/src/lib.rs b/crates/bevy_scene/macros/src/lib.rs index f93d495211687..ec3d7dfc886ba 100644 --- a/crates/bevy_scene/macros/src/lib.rs +++ b/crates/bevy_scene/macros/src/lib.rs @@ -1,6 +1,8 @@ mod bsn; +mod scene_component; use proc_macro::TokenStream; +use syn::{parse_macro_input, DeriveInput}; #[proc_macro] pub fn bsn(input: TokenStream) -> TokenStream { @@ -11,3 +13,12 @@ pub fn bsn(input: TokenStream) -> TokenStream { pub fn bsn_list(input: TokenStream) -> TokenStream { crate::bsn::bsn_list(input) } + +#[proc_macro_derive( + SceneComponent, + attributes(component, require, relationship, relationship_target, entities, scene) +)] +pub fn derive_scene_component(input: TokenStream) -> TokenStream { + let mut ast = parse_macro_input!(input as DeriveInput); + TokenStream::from(scene_component::derive_scene_component(&mut ast)) +} diff --git a/crates/bevy_scene/macros/src/scene_component.rs b/crates/bevy_scene/macros/src/scene_component.rs new file mode 100644 index 0000000000000..bbe6370117c9b --- /dev/null +++ b/crates/bevy_scene/macros/src/scene_component.rs @@ -0,0 +1,122 @@ +use bevy_ecs_macro_logic::component::DeriveComponent; +use bevy_macro_utils::{BevyManifest, PathType}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{ + parenthesized, + parse::{Parse, ParseStream}, + parse_str, + token::Paren, + DeriveInput, LitStr, Path, +}; + +pub(crate) fn derive_scene_component(ast: &mut DeriveInput) -> TokenStream { + let mut derive_component = match DeriveComponent::parse(ast) { + Ok(value) => value, + Err(e) => return e.into_compile_error(), + }; + + let (bevy_ecs, bevy_scene) = BevyManifest::shared(|manifest| { + ( + manifest.get_path("bevy_ecs"), + manifest.get_path("bevy_scene"), + ) + }); + + let scene = match parse_attrs(ast) { + Ok(attrs) => attrs, + Err(err) => { + return err.into_compile_error(); + } + }; + + let scene = scene.unwrap_or_default(); + + let (scene_impl, props_type) = match scene { + Scene::Function(path) => (quote! {#path()}, quote! {()}), + Scene::Asset(lit_str) => ( + quote! {#bevy_scene::InheritSceneAsset::from(#lit_str)}, + quote! {()}, + ), + Scene::FunctionProps { function, props } => (quote! {#function(props)}, quote! {#props}), + }; + + let struct_name = &ast.ident; + let (_, type_generics, _) = &ast.generics.split_for_impl(); + derive_component.additional_requires.push(quote! { + required_components.register_required(|| #bevy_scene::SceneComponentInfo::new::<#struct_name #type_generics>(false)); + }); + let component_impl = derive_component.impl_component(ast, &bevy_ecs); + let struct_name = &ast.ident; + let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); + quote! { + #component_impl + + impl #impl_generics #bevy_scene::SceneComponent for #struct_name #type_generics #where_clause { + type Props = #props_type; + fn scene(props: Self::Props) -> impl Scene { + ( + #scene_impl, + #bevy_scene::InitTemplate::<<#struct_name #type_generics as #bevy_ecs::template::FromTemplate>::Template>::default(), + #bevy_scene::template_value(#bevy_scene::SceneComponentInfo::new::<#struct_name #type_generics>(true)), + ) + } + } + } +} + +fn parse_attrs(ast: &DeriveInput) -> syn::Result> { + let mut scene = None; + for attr in &ast.attrs { + if attr.path().is_ident("scene") { + scene = Some(attr.parse_args::()?); + } + } + Ok(scene) +} + +/// Parsed scene information +pub(crate) enum Scene { + Function(Path), + FunctionProps { function: Path, props: Path }, + Asset(LitStr), +} + +impl Default for Scene { + fn default() -> Self { + Scene::Function(default_path()) + } +} + +fn default_path() -> Path { + // Self::scene will always parse correctly + parse_str::("Self::scene").unwrap() +} + +impl Parse for Scene { + fn parse(input: ParseStream) -> syn::Result { + Ok(if input.peek(LitStr) { + Scene::Asset(input.parse::()?) + } else { + let path = input.parse::()?; + if let PathType::Type = PathType::new(&path) { + return Ok(Scene::FunctionProps { + function: default_path(), + props: path, + }); + } + + if input.peek(Paren) { + let content; + parenthesized!(content in input); + let props = content.parse::()?; + Scene::FunctionProps { + function: path, + props, + } + } else { + Scene::Function(path) + } + }) + } +} diff --git a/crates/bevy_scene/src/lib.rs b/crates/bevy_scene/src/lib.rs index 6bbe0cfa64dea..9343baf585596 100644 --- a/crates/bevy_scene/src/lib.rs +++ b/crates/bevy_scene/src/lib.rs @@ -508,7 +508,7 @@ pub mod prelude { pub use crate::{ bsn, bsn_list, on, template_value, CommandsSceneExt, EntityCommandsSceneExt, - EntityWorldMutSceneExt, PatchFromTemplate, PatchTemplate, Scene, SceneList, + EntityWorldMutSceneExt, PatchFromTemplate, PatchTemplate, Scene, SceneComponent, SceneList, ScenePatchInstance, SpawnListSystem, SpawnSystem, WorldSceneExt, }; } @@ -520,6 +520,7 @@ extern crate alloc; mod resolved_scene; mod scene; +mod scene_component; mod scene_list; mod scene_patch; mod spawn; @@ -528,6 +529,7 @@ mod spawn_system; pub use bevy_scene_macros::*; pub use resolved_scene::*; pub use scene::*; +pub use scene_component::*; pub use scene_list::*; pub use scene_patch::*; pub use spawn::*; @@ -572,6 +574,7 @@ mod tests { use bevy_ecs::relationship::Relationship; use bevy_ecs::world::DeferredWorld; use bevy_reflect::TypePath; + use bevy_scene_macros::SceneComponent; use std::path::Path; use std::sync::Mutex; @@ -656,8 +659,8 @@ mod tests { } } - #[derive(Component, Default, Clone)] - #[component(scene = "a.bsn")] + #[derive(SceneComponent, Default, Clone)] + #[scene("a.bsn")] struct AWidget { value: usize, } @@ -1432,8 +1435,7 @@ mod tests { #[test] fn component_scene() { - #[derive(Component, Default, Clone)] - #[component(scene)] + #[derive(SceneComponent, Default, Clone)] struct Widget; impl Widget { @@ -1448,8 +1450,8 @@ mod tests { assert_eq!(entity.get::().unwrap().as_str(), "widget"); assert!(entity.contains::()); - #[derive(Component, Default, Clone)] - #[component(scene = Widget::scene)] + #[derive(SceneComponent, Default, Clone)] + #[scene(Widget::scene)] struct OtherWidget; let entity = world.spawn_scene(bsn! {:OtherWidget}).unwrap(); assert_eq!(entity.get::().unwrap().as_str(), "widget"); @@ -1462,8 +1464,8 @@ mod tests { #[test] fn component_scene_props() { - #[derive(Component, Default, Clone)] - #[component(scene_props = WidgetProps)] + #[derive(SceneComponent, Default, Clone)] + #[scene(WidgetProps)] struct Widget { value: usize, } @@ -1501,8 +1503,8 @@ mod tests { assert_eq!(entity.get::().unwrap().value, 10); assert_eq!(entity.get::().unwrap().len(), 2); - #[derive(Component, Default, Clone)] - #[component(scene = Widget::scene, scene_props = WidgetProps)] + #[derive(SceneComponent, Default, Clone)] + #[scene(Widget::scene(WidgetProps))] struct OtherWidget { value: usize, } @@ -1519,8 +1521,7 @@ mod tests { #[test] fn scene_without_explicit_component_still_spawns_component() { - #[derive(Component, Default, Clone)] - #[component(scene)] + #[derive(SceneComponent, Default, Clone)] struct Widget; impl Widget { diff --git a/crates/bevy_scene/src/scene.rs b/crates/bevy_scene/src/scene.rs index 75f0e7c5151c5..66ee4d0963553 100644 --- a/crates/bevy_scene/src/scene.rs +++ b/crates/bevy_scene/src/scene.rs @@ -5,17 +5,13 @@ use bevy_ecs::{ component::Component, error::Result, event::EntityEvent, - lifecycle::HookContext, name::Name, - reflect::ReflectComponent, relationship::Relationship, system::IntoObserverSystem, template::{ EntityScopes, FnTemplate, FromTemplate, ScopedEntityIndex, Template, TemplateContext, }, - world::DeferredWorld, }; -use bevy_reflect::Reflect; use core::{any::TypeId, marker::PhantomData}; use thiserror::Error; use variadics_please::all_tuples; @@ -545,24 +541,6 @@ pub fn on, E: EntityEvent, B: Bundle, M: 'static> OnTemplate(observer, PhantomData) } -/// Implemented for [`Component`]s that have an associated [`Scene`], which can be constructed -/// with [`Self::Props`]. -/// -/// In general, developers should not implement this manually. Instead, they should specify `#[component(scene)]` -/// in the [`Component`] derive, which will derive this trait and add additional protections and assurances. -/// -/// See the "Scene Components" sections of the [`Scene`] docs to see how this is used in practice. -pub trait SceneConstructor: FromTemplate -where - ::Output: Component, -{ - /// The "properties" passed into [`Self::scene`] to build the final scene. - type Props: Default; - - /// A function that uses the given `props` to produce a [`Scene`] - fn scene(props: Self::Props) -> impl Scene; -} - impl From> for Box { fn from(value: SceneScope) -> Self { Box::new(value) @@ -595,44 +573,3 @@ impl + Default + Send + Sync + 'static> Scene for Ok(()) } } - -/// A [`Component`] that must always be spawned with a [`Scene`]. -#[derive(Component, Default, Clone, Debug, Reflect)] -#[cfg_attr(debug_assertions, component(on_add))] -#[reflect(Component)] -pub struct SceneComponentInfo { - spawned_from_scene: bool, - #[cfg(debug_assertions)] - component_name: &'static str, -} - -impl SceneComponentInfo { - /// Creates a new [`SceneComponentInfo`] for the given type `C`. - pub fn new(spawned_from_scene: bool) -> Self { - SceneComponentInfo { - spawned_from_scene, - #[cfg(debug_assertions)] - component_name: core::any::type_name::(), - } - } -} - -impl SceneComponentInfo { - #[cfg(debug_assertions)] - fn on_add(world: DeferredWorld, context: HookContext) { - if let Ok(entity) = world.get_entity(context.entity) - && let Some(component) = entity.get::() - && !component.spawned_from_scene - { - tracing::error!( - "Entity {} was spawned with the \"scene component\" {}, but without its scene. \ - Scene components should not be spawned directly as components. Instead, they \ - should be spawned as \"scenes\" using world.spawn_scene or commands.spawn_scene. \ - Scene components should be inherited using `:{}` syntax in BSN.", - context.entity, - component.component_name, - component.component_name - ); - } - } -} diff --git a/crates/bevy_scene/src/scene_component.rs b/crates/bevy_scene/src/scene_component.rs new file mode 100644 index 0000000000000..fa1e24950f390 --- /dev/null +++ b/crates/bevy_scene/src/scene_component.rs @@ -0,0 +1,60 @@ +use bevy_ecs::{component::Component, reflect::ReflectComponent, template::FromTemplate}; +use bevy_reflect::Reflect; + +use crate::Scene; + +/// Implemented for [`Component`]s that have an associated [`Scene`], which can be constructed +/// with [`Self::Props`]. +/// +/// In general, developers should not implement this manually. Instead, they should derive it, +/// which also derives [`Component`] and adds additional protections and assurances. +/// +/// See the "Scene Components" sections of the [`Scene`] docs to see how this is used in practice. +pub trait SceneComponent: Component + FromTemplate { + /// The "properties" passed into [`Self::scene`] to build the final scene. + type Props: Default; + + /// A function that uses the given `props` to produce a [`Scene`] + fn scene(props: Self::Props) -> impl Scene; +} + +/// A [`Component`] that must always be spawned with a [`Scene`]. +#[derive(Component, Default, Clone, Debug, Reflect)] +#[cfg_attr(debug_assertions, component(on_add))] +#[reflect(Component)] +pub struct SceneComponentInfo { + spawned_from_scene: bool, + #[cfg(debug_assertions)] + component_name: &'static str, +} + +impl SceneComponentInfo { + /// Creates a new [`SceneComponentInfo`] for the given type `C`. + pub fn new(spawned_from_scene: bool) -> Self { + SceneComponentInfo { + spawned_from_scene, + #[cfg(debug_assertions)] + component_name: core::any::type_name::(), + } + } +} + +impl SceneComponentInfo { + #[cfg(debug_assertions)] + fn on_add(world: bevy_ecs::world::DeferredWorld, context: bevy_ecs::lifecycle::HookContext) { + if let Ok(entity) = world.get_entity(context.entity) + && let Some(component) = entity.get::() + && !component.spawned_from_scene + { + tracing::error!( + "Entity {} was spawned with the \"scene component\" {}, but without its scene. \ + Scene components should not be spawned directly as components. Instead, they \ + should be spawned as \"scenes\" using world.spawn_scene or commands.spawn_scene. \ + Scene components should be inherited using `:{}` syntax in BSN.", + context.entity, + component.component_name, + component.component_name + ); + } + } +} From 66ae157f1856cc43e7afb43ce285de107c4ad8fd Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Wed, 29 Apr 2026 20:16:14 -0700 Subject: [PATCH 10/15] Update doc --- crates/bevy_scene/src/scene_component.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_scene/src/scene_component.rs b/crates/bevy_scene/src/scene_component.rs index fa1e24950f390..a20916a9e35e0 100644 --- a/crates/bevy_scene/src/scene_component.rs +++ b/crates/bevy_scene/src/scene_component.rs @@ -18,7 +18,7 @@ pub trait SceneComponent: Component + FromTemplate { fn scene(props: Self::Props) -> impl Scene; } -/// A [`Component`] that must always be spawned with a [`Scene`]. +/// Indicates that this entity includes a [`Component`] that must always be spawned with a [`Scene`]. #[derive(Component, Default, Clone, Debug, Reflect)] #[cfg_attr(debug_assertions, component(on_add))] #[reflect(Component)] From ee1ac624985d9b9e44df3691dc4ddad452fa4cb9 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Thu, 30 Apr 2026 17:50:03 -0700 Subject: [PATCH 11/15] Add docs --- crates/bevy_scene/src/lib.rs | 277 +++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) diff --git a/crates/bevy_scene/src/lib.rs b/crates/bevy_scene/src/lib.rs index 9343baf585596..c9ff77b230115 100644 --- a/crates/bevy_scene/src/lib.rs +++ b/crates/bevy_scene/src/lib.rs @@ -474,6 +474,283 @@ //! commands.spawn_scene(unit_with_armor(my_unit)); //! ``` //! +//! ## Scene Components +//! +//! A [`SceneComponent`] is a specialized type of [`Component`] that has an associated [`Scene`]: +//! +//! ``` +//! # use bevy_scene::prelude::*; +//! # use bevy_ecs::prelude::*; +//! # #[derive(Component, Default, Clone)] +//! # struct Sword; +//! # #[derive(Component, Default, Clone)] +//! # struct Shield; +//! #[derive(SceneComponent, Default, Clone)] +//! struct Player { +//! score: usize +//! } +//! +//! impl Player { +//! fn scene() -> impl Scene { +//! bsn! { +//! #Player +//! Children [ +//! Sword, +//! Shield, +//! ] +//! } +//! } +//! } +//! ``` +//! +//! This enables inheriting the [`SceneComponent`] as a scene, using the following syntax: +//! +//! ```no_run +//! # use bevy_scene::prelude::*; +//! # use bevy_ecs::prelude::*; +//! # #[derive(SceneComponent, Default, Clone)] +//! # struct Player { +//! # score: usize +//! # } +//! # impl Player { +//! # fn scene() -> impl Scene {} +//! # } +//! # let mut world = World::new(); +//! world.spawn_scene(bsn! { +//! :Player { score: 0 } +//! }); +//! ``` +//! +//! This will spawn the `Player` component _and_ the entire scene with it. This means that you write +//! systems that query for the `Player` component, they can assume the rest of the scene will be there +//! too! +//! +//! [`SceneComponent`]s can only be spawned using scene APIs like [`World::spawn_scene`]. Spawning +//! them using [`World::spawn`] will log an error. +//! +//! ### Inheritance Syntax vs Patch Syntax +//! +//! Notice that this uses inheritance syntax in BSN (`:`), rather than normal "component patch" syntax +//! (ex: `bsn! { Player { score: 0 } }`. Semantically these are different things: +//! - Scene inheritance syntax: constructs the full scene and inherits from it +//! - Component patch syntax: _Just_ patches the component directly and creates it if it doesn't exist. +//! This will not do any scene inheritance. You can still patch scene components this way as long +//! as the scene component is inherited somewhere "earlier" in the inheritance hierarchy. +//! +//! ### Custom Scene Functions +//! +//! When deriving [`SceneComponent`], it defaults to using `Self::scene` as the "scene function". +//! Scene functions can also be manually specified: +//! +//! ``` +//! # use bevy_scene::prelude::*; +//! # use bevy_ecs::prelude::*; +//! #[derive(SceneComponent, Default, Clone)] +//! #[scene(player)] +//! struct Player; +//! +//! fn player() -> impl Scene { +//! bsn! { /* scene here */} +//! } +//! ``` +//! +//! ### `SceneComponent` Asset Paths +//! +//! Alternatively, a scene asset path can be specified: +//! +//! ``` +//! # use bevy_scene::prelude::*; +//! # use bevy_ecs::prelude::*; +//! #[derive(SceneComponent, Default, Clone)] +//! #[scene("player.bsn")] +//! struct Player { +//! score: usize +//! } +//! ``` +//! +//! (Note that we haven't yet landed the `.bsn` asset format or ported the glTF asset loader to BSN) +//! +//! ### Scene Components are Template-able +//! +//! Just like other [`Component`]s, [`SceneComponent`]s are "template-able" +//! +//! ```no_run +//! # use bevy_scene::prelude::*; +//! # use bevy_ecs::{prelude::*, template::TemplateContext}; +//! # let mut world = World::new(); +//! # struct Handle(std::marker::PhantomData); +//! # struct HandleTemplate(String, std::marker::PhantomData); +//! # impl<'a, T> From<&'a str> for HandleTemplate { +//! # fn from(value: &'a str) -> Self { todo!() } +//! # } +//! # impl Default for HandleTemplate { +//! # fn default() -> Self { todo!() } +//! # } +//! # struct Image; +//! # impl Template for HandleTemplate { +//! # type Output = Handle; +//! # fn build_template(&self, context: &mut TemplateContext) -> Result> { todo!() } +//! # fn clone_template(&self) -> Self { todo!() } +//! # } +//! # impl FromTemplate for Handle { +//! # type Template = HandleTemplate; +//! # } +//! #[derive(SceneComponent, FromTemplate)] +//! struct Player { +//! image: Handle, +//! } +//! +//! impl Player { +//! fn scene() -> impl Scene { +//! bsn! { /* scene here */} +//! } +//! } +//! +//! world.spawn_scene(bsn! { +//! :Player { image: "player.png" } +//! }); +//! ``` +//! +//! ### `SceneComponent` Props +//! +//! Sometimes it is desirable to "parameterize" a scene: pass in values to the scene which determine +//! what the scene outputs are. The answer to this in BSN is "scene props": +//! +//! ```no_run +//! # use bevy_scene::prelude::*; +//! # use bevy_ecs::prelude::*; +//! # #[derive(Component, Default, Clone)] +//! # struct Node; +//! # #[derive(Component, Default, Clone)] +//! # struct Text(String); +//! # let mut world = World::new(); +//! /// A UI widget that repeats "hello" text a given number of times. +//! #[derive(SceneComponent, Default, Clone)] +//! #[scene(HelloRepeaterProps)] +//! struct HelloRepeater; +//! +//! #[derive(Default)] +//! struct HelloRepeaterProps { +//! repeat: usize, +//! } +//! +//! impl HelloRepeater { +//! fn scene(props: HelloRepeaterProps) -> impl Scene { +//! let hellos = (0..props.repeat) +//! .map(|_| bsn!{ Text("hello") }) +//! .collect::>(); +//! bsn! { +//! Node +//! Children [ +//! {hellos} +//! ] +//! } +//! } +//! } +//! +//! world.spawn_scene(bsn! { +//! :HelloRepeater { +//! @repeat: 5 +//! } +//! }); +//! ``` +//! +//! Notice the `@field` syntax, which specifies that a prop is being set instead of a field. +//! Props are evaluated "immediately" at the point of inheritance where the scene is constructed. +//! This means that they are not "patchable". +//! +//! You can set _both_ props and normal fields at the same time: +//! ```no_run +//! # use bevy_scene::prelude::*; +//! # use bevy_ecs::prelude::*; +//! # let mut world = World::new(); +//! # impl Widget { +//! # fn scene(props: WidgetProps) -> impl Scene {} +//! # } +//! #[derive(SceneComponent, Default, Clone)] +//! #[scene(WidgetProps)] +//! struct Widget { +//! value: usize +//! } +//! +//! #[derive(Default)] +//! struct WidgetProps { +//! border: bool, +//! } +//! +//! world.spawn_scene(bsn! { +//! :Widget { +//! @border: true, +//! value: 10, +//! } +//! }); +//! ``` +//! +//! ### The Scene Component is Always Added +//! +//! Specifying the scene component manually in the scene function is not necessary. It will be added +//! automatically: +//! +//! ``` +//! # use bevy_scene::prelude::*; +//! #[derive(SceneComponent, Default, Clone)] +//! struct Player; +//! +//! impl Player { +//! fn scene() -> impl Scene { +//! bsn! { +//! // No need to specify a Player component here. +//! // It is implied! +//! } +//! } +//! } +//! ``` +//! However you _can_ patch the scene component in the scene if you would like. This comes in handy +//! if you would like props to contribute to the scene component's value: +//! +//! ``` +//! # use bevy_scene::prelude::*; +//! # #[derive(Default)] +//! # struct PlayerProps { size_in_millimeters: f32 }; +//! # #[derive(SceneComponent, Default, Clone)] +//! # #[scene(PlayerProps)] +//! # struct Player { size_in_meters: f32 } +//! impl Player { +//! fn scene(props: PlayerProps) -> impl Scene { +//! bsn! { +//! Player { +//! size_in_meters: {props.size_in_millimeters / 1000. } +//! } +//! } +//! } +//! } +//! ``` +//! +//! ### Scene Components vs Required Components +//! +//! At first glace, Scene Components and [Required Components](bevy_ecs::component::Component) solve +//! similar problems. They both provide a mechanism to initialize components with other components. +//! +//! They are functionally quite different however. It is worth understanding the differences and +//! tradeoffs: +//! +//! - **Required Components**: Context-less (ex: Default constructors), non-hierarchical, can alway +//! be applied immediately, not dependency aware, automatically enforced at runtime as components +//! are added, not patchable, pretty low overhead, not a lot of features / functionality +//! - **Scene Components**: Require context (ex: World access and "Entity Spawn Context", such as +//! entity references), hierarchical (spawn children), cannot always be applied immediately +//! (can have dependencies that aren't loaded yet), dependency aware, only enforced at spawn +//! time, patchable, more dynamic / higher overhead, many features. +//! +//! Some good rules of thumb: +//! +//! - Are you building something "hierarchical" / with related entities? Use [`SceneComponent`]. +//! - Do you want or need the full capabilities of the scene system? Use [`SceneComponent`]. +//! - Are you spawning something that has dependencies / needs World access? use [`SceneComponent`]. +//! - Are you defining "flat" components that aren't really scenes on their own? Use required components. +//! - Do you need the "required" components to be automatically added in non-scene contexts? Use required components. +//! - Is spawn performance a very high priority? Use required components. +//! //! ## .bsn Asset Format //! //! In future releases, Bevy intends to offer a `.bsn` asset format. From 31cbcc96f358178a5260aa2aceb433aaf3846b42 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Thu, 30 Apr 2026 17:51:34 -0700 Subject: [PATCH 12/15] typo --- crates/bevy_scene/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_scene/src/lib.rs b/crates/bevy_scene/src/lib.rs index c9ff77b230115..8301aa8f91197 100644 --- a/crates/bevy_scene/src/lib.rs +++ b/crates/bevy_scene/src/lib.rs @@ -734,7 +734,7 @@ //! They are functionally quite different however. It is worth understanding the differences and //! tradeoffs: //! -//! - **Required Components**: Context-less (ex: Default constructors), non-hierarchical, can alway +//! - **Required Components**: Context-less (ex: Default constructors), non-hierarchical, can always //! be applied immediately, not dependency aware, automatically enforced at runtime as components //! are added, not patchable, pretty low overhead, not a lot of features / functionality //! - **Scene Components**: Require context (ex: World access and "Entity Spawn Context", such as From aa13f4e8fbabe7f2acc7965df5b65e9bc2e38272 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Thu, 30 Apr 2026 18:26:12 -0700 Subject: [PATCH 13/15] Re-add static detection --- crates/bevy_ecs/macro_logic/src/component.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/bevy_ecs/macro_logic/src/component.rs b/crates/bevy_ecs/macro_logic/src/component.rs index 602dd52a9bcc8..cbc7f63a48020 100644 --- a/crates/bevy_ecs/macro_logic/src/component.rs +++ b/crates/bevy_ecs/macro_logic/src/component.rs @@ -156,6 +156,21 @@ impl DeriveComponent { /// /// Note that this will add Send + Sync + 'static to the where clause pub fn impl_component(self, ast: &mut DeriveInput, bevy_ecs: &Path) -> TokenStream { + // We want to raise a compile time error when the generic lifetimes + // are not bound to 'static lifetime + let non_static_lifetime_error = ast + .generics + .lifetimes() + .filter(|lifetime| !lifetime.bounds.iter().any(|bound| bound.ident == "static")) + .map(|param| syn::Error::new(param.span(), "Lifetimes must be 'static")) + .reduce(|mut err_acc, err| { + err_acc.combine(err); + err_acc + }); + if let Some(err) = non_static_lifetime_error { + return err.into_compile_error(); + } + let relationship = match self.derive_relationship(ast, bevy_ecs) { Ok(value) => value, Err(err) => Some(err.into_compile_error()), From 6850d6df663cd92b86bd847cb8b0e04ec46a7d55 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Thu, 30 Apr 2026 18:29:10 -0700 Subject: [PATCH 14/15] Fix name path --- crates/bevy_scene/macros/src/bsn/codegen.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_scene/macros/src/bsn/codegen.rs b/crates/bevy_scene/macros/src/bsn/codegen.rs index 0c5bffd4df3a1..b3875fbb725db 100644 --- a/crates/bevy_scene/macros/src/bsn/codegen.rs +++ b/crates/bevy_scene/macros/src/bsn/codegen.rs @@ -277,7 +277,7 @@ impl BsnEntry { } BsnEntry::NameExpression(expr) => Ok(quote! { <#bevy_ecs::name::Name as #bevy_scene::PatchFromTemplate>::patch(move |value, _context| { - *value = #bevy_ecs::Name({#expr}.into()); + *value = #bevy_ecs::name::Name({#expr}.into()); }) }), } From d377bd1b705ab0e26354ab080a30f641e269b6b8 Mon Sep 17 00:00:00 2001 From: Carter Anderson Date: Thu, 30 Apr 2026 19:21:46 -0700 Subject: [PATCH 15/15] Fail faster to fix compile_fail test --- crates/bevy_ecs/macro_logic/src/component.rs | 8 ++++---- crates/bevy_ecs/macros/src/lib.rs | 6 +++++- crates/bevy_ecs/macros/src/resource.rs | 5 ++++- crates/bevy_scene/macros/src/scene_component.rs | 5 ++++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/bevy_ecs/macro_logic/src/component.rs b/crates/bevy_ecs/macro_logic/src/component.rs index cbc7f63a48020..982b4b332ec66 100644 --- a/crates/bevy_ecs/macro_logic/src/component.rs +++ b/crates/bevy_ecs/macro_logic/src/component.rs @@ -155,7 +155,7 @@ impl DeriveComponent { /// Generates a new `Component` trait implementation from this specification. /// /// Note that this will add Send + Sync + 'static to the where clause - pub fn impl_component(self, ast: &mut DeriveInput, bevy_ecs: &Path) -> TokenStream { + pub fn impl_component(self, ast: &mut DeriveInput, bevy_ecs: &Path) -> Result { // We want to raise a compile time error when the generic lifetimes // are not bound to 'static lifetime let non_static_lifetime_error = ast @@ -168,7 +168,7 @@ impl DeriveComponent { err_acc }); if let Some(err) = non_static_lifetime_error { - return err.into_compile_error(); + return Err(err); } let relationship = match self.derive_relationship(ast, bevy_ecs) { @@ -318,7 +318,7 @@ impl DeriveComponent { } else { quote! {::core::option::Option::None} }; - quote! { + Ok(quote! { #required_component_docs impl #impl_generics #bevy_ecs::component::Component for #struct_name #type_generics #where_clause { const STORAGE_TYPE: #bevy_ecs::component::StorageType = #storage; @@ -351,7 +351,7 @@ impl DeriveComponent { #relationship #relationship_target - } + }) } fn derive_relationship( &self, diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index a5cd0e8fc116f..3d8d6991d1208 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -807,7 +807,11 @@ pub fn derive_component(input: TokenStream) -> TokenStream { Err(e) => return e.into_compile_error().into(), }; let bevy_ecs = bevy_ecs_path(); - TokenStream::from(derive_component.impl_component(&mut ast, &bevy_ecs)) + let impl_component = match derive_component.impl_component(&mut ast, &bevy_ecs) { + Ok(value) => value, + Err(err) => return err.into_compile_error().into(), + }; + TokenStream::from(impl_component) } /// Implement the `FromWorld` trait. diff --git a/crates/bevy_ecs/macros/src/resource.rs b/crates/bevy_ecs/macros/src/resource.rs index c85ac6b7802f5..ab2fd00dda429 100644 --- a/crates/bevy_ecs/macros/src/resource.rs +++ b/crates/bevy_ecs/macros/src/resource.rs @@ -23,7 +23,10 @@ pub fn derive_resource(ast: &mut DeriveInput) -> TokenStream { required_components.register_required::<#bevy_ecs::resource::IsResource>(move || #bevy_ecs::resource::IsResource::new(resource_component_id)); }); - let component_impl = derive_component.impl_component(ast, &bevy_ecs); + let component_impl = match derive_component.impl_component(ast, &bevy_ecs) { + Ok(value) => value, + Err(err) => return err.into_compile_error(), + }; let struct_name = &ast.ident; let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); diff --git a/crates/bevy_scene/macros/src/scene_component.rs b/crates/bevy_scene/macros/src/scene_component.rs index bbe6370117c9b..3641f7da9cba3 100644 --- a/crates/bevy_scene/macros/src/scene_component.rs +++ b/crates/bevy_scene/macros/src/scene_component.rs @@ -46,7 +46,10 @@ pub(crate) fn derive_scene_component(ast: &mut DeriveInput) -> TokenStream { derive_component.additional_requires.push(quote! { required_components.register_required(|| #bevy_scene::SceneComponentInfo::new::<#struct_name #type_generics>(false)); }); - let component_impl = derive_component.impl_component(ast, &bevy_ecs); + let component_impl = match derive_component.impl_component(ast, &bevy_ecs) { + Ok(value) => value, + Err(err) => return err.into_compile_error(), + }; let struct_name = &ast.ident; let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); quote! {