From 588989a4d09b81982ea386a090c85188031d6077 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 25 Jun 2025 08:03:37 -0700 Subject: [PATCH 1/3] Start work on core menus --- crates/bevy_core_widgets/src/core_menu.rs | 78 +++++++ crates/bevy_core_widgets/src/floating.rs | 250 ++++++++++++++++++++++ crates/bevy_core_widgets/src/lib.rs | 6 + crates/bevy_core_widgets/src/portal.rs | 34 +++ 4 files changed, 368 insertions(+) create mode 100644 crates/bevy_core_widgets/src/core_menu.rs create mode 100644 crates/bevy_core_widgets/src/floating.rs create mode 100644 crates/bevy_core_widgets/src/portal.rs diff --git a/crates/bevy_core_widgets/src/core_menu.rs b/crates/bevy_core_widgets/src/core_menu.rs new file mode 100644 index 0000000000000..67d88716720ff --- /dev/null +++ b/crates/bevy_core_widgets/src/core_menu.rs @@ -0,0 +1,78 @@ +//! Core widget components for menus and menu buttons. + +use accesskit::Role; +use bevy_a11y::AccessibilityNode; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::{EntityEvent, Event}, + system::SystemId, + traversal::Traversal, +}; + +use crate::portal::{PortalTraversal, PortalTraversalItem}; + +/// Event use to control the state of the open menu. This bubbles upwards from the menu items +/// and the menu container, through the portal relation, and to the menu owner entity. +/// +/// Focus navigation: the menu may be part of a composite of multiple menus such as a menu bar. +/// This means that depending on direction, focus movement may move to the next menu item, or +/// the next menu. +#[derive(Event, EntityEvent, Clone)] +pub enum MenuEvent { + /// Indicates we want to open the menu, if it is not already open. + Open, + /// Close the menu and despawn it. Despawning may not happen immediately if there is a closing + /// transition animation. + Close, + /// Move the input focs to the parent element. This usually happens as the menu is closing, + /// although will not happen if the close was a result of clicking on the background. + FocusParent, + /// Move the input focus to the previous child in the parent's hierarchy (Shift-Tab). + FocusPrev, + /// Move the input focus to the next child in the parent's hierarchy (Tab). + FocusNext, + /// Move the input focus up (Arrow-Up). + FocusUp, + /// Move the input focus down (Arrow-Down). + FocusDown, + /// Move the input focus left (Arrow-Left). + FocusLeft, + /// Move the input focus right (Arrow-Right). + FocusRight, +} + +impl Traversal for PortalTraversal { + fn traverse(item: Self::Item<'_, '_>, _event: &MenuEvent) -> Option { + let PortalTraversalItem { + child_of, + portal_child_of, + } = item; + + // Send event to portal parent, if it has one. + if let Some(portal_child_of) = portal_child_of { + return Some(portal_child_of.parent()); + }; + + // Send event to parent, if it has one. + if let Some(child_of) = child_of { + return Some(child_of.parent()); + }; + + None + } +} + +/// Component that defines a popup menu +#[derive(Component, Debug)] +#[require(AccessibilityNode(accesskit::Node::new(Role::MenuListPopup)))] +pub struct CoreMenuPopup; + +/// Component that defines a menu item +#[derive(Component, Debug)] +#[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))] +pub struct CoreMenuItem { + /// Optional system to run when the menu item is clicked, or when the Enter or Space key + /// is pressed while the button is focused. + pub on_click: Option, +} diff --git a/crates/bevy_core_widgets/src/floating.rs b/crates/bevy_core_widgets/src/floating.rs new file mode 100644 index 0000000000000..ce228b38de189 --- /dev/null +++ b/crates/bevy_core_widgets/src/floating.rs @@ -0,0 +1,250 @@ +//! Framework for positioning of popups, tooltips, and other floating UI elements. + +use bevy_app::{App, Plugin, PostUpdate}; +use bevy_ecs::{component::Component, entity::Entity, query::Without, system::Query}; +use bevy_math::{Rect, Vec2}; +use bevy_ui::{ComputedNode, ComputedNodeTarget, Node, UiGlobalTransform, Val}; + +/// Which side of the anchor element the floating element should be placed. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum FloatSide { + /// The floating element should be placed above the anchor. + Top, + /// The floating element should be placed below the anchor. + #[default] + Bottom, + /// The floating element should be placed to the left of the anchor. + Left, + /// The floating element should be placed to the right of the anchor. + Right, +} + +impl FloatSide { + /// Returns the side that is the mirror image of this side. + pub fn mirror(&self) -> Self { + match self { + FloatSide::Top => FloatSide::Bottom, + FloatSide::Bottom => FloatSide::Top, + FloatSide::Left => FloatSide::Right, + FloatSide::Right => FloatSide::Left, + } + } +} + +/// How the floating element should be aligned to the anchor element. The alignment will be along an +/// axis that is perpendicular to the direction of the float side. So for example, if the popup is +/// positioned below the anchor, then the [`FloatAlign`] variant controls the horizontal aligment of +/// the popup. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum FloatAlign { + /// The starting edge of the floating element should be aligned to the starting edge of the + /// anchor. + #[default] + Start, + /// The ending edge of the floating element should be aligned to the ending edge of the anchor. + End, + /// The center of the floating element should be aligned to the center of the anchor. + Center, +} + +/// Indicates a possible position of a floating element relative to an anchor element. You can +/// specify multiple possible positions; the positioning code will check to see if there is +/// sufficient space to display the popup without clipping. If any position has sufficient room, +/// it will pick the first one; if there are none, then it will pick the least bad one. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct FloatPosition { + /// The side of the anchor the floating element should be placed. + pub side: FloatSide, + + /// How the floating element should be aligned to the anchor. + pub align: FloatAlign, + + /// If true, the floating element will be at least as large as the anchor on the adjacent + /// side. + pub stretch: bool, + + /// The size of the gap between the anchor and the floating element. This will offset the + /// float along the direction of the [`FloatSide`]. + pub gap: f32, +} + +/// Defines the anchor position which the floating element is positioned relative to. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FloatAnchor { + /// The anchor is an entity with a UI [`Node`] component. + Node(Entity), + /// The anchor is an arbitrary rectangle in window coordinates. + Rect(Rect), +} + +/// Component which is inserted into a floating element to make it dynamically position relative to +/// an anchor element. +#[derive(Component, PartialEq)] +pub struct Floating { + /// The entity that this floating element is anchored to. + pub anchor: FloatAnchor, + + /// List of potential positions for the floating element relative to the anchor. + pub positions: Vec, +} + +impl Clone for Floating { + fn clone(&self) -> Self { + Self { + anchor: self.anchor, + positions: self.positions.clone(), + } + } +} + +fn position_floating( + mut q_float: Query<(&mut Node, &ComputedNode, &ComputedNodeTarget, &Floating)>, + q_anchor: Query<(&ComputedNode, &UiGlobalTransform), Without>, +) { + for (mut node, computed_node, computed_target, floating) in q_float.iter_mut() { + // A rectangle which represents the area of the window. + let window_rect = Rect { + min: Vec2::ZERO, + max: computed_target.logical_size(), + }; + + // Compute the anchor rectangle. + let anchor_rect: Rect = match floating.anchor { + FloatAnchor::Node(anchor_entity) => { + let Ok((anchor_node, anchor_transform)) = q_anchor.get(anchor_entity) else { + continue; + }; + Rect::from_center_size(anchor_transform.translation, anchor_node.size()) + } + FloatAnchor::Rect(rect) => rect, + }; + + let mut best_occluded = f32::MAX; + let mut best_rect = Rect::default(); + let mut best_position: FloatPosition = Default::default(); + + // Loop through all the potential positions and find a good one. + for position in &floating.positions { + let float_size = computed_node.size(); + let mut rect = Rect::default(); + + // Taraget width and height depends on whether 'stretch' is true. + let target_width = if position.stretch && position.side == FloatSide::Top + || position.side == FloatSide::Bottom + { + float_size.x.max(anchor_rect.width()) + } else { + float_size.x + }; + + let target_height = if position.stretch && position.side == FloatSide::Left + || position.side == FloatSide::Right + { + float_size.y.max(anchor_rect.height()) + } else { + float_size.y + }; + + // Position along main axis. + match position.side { + FloatSide::Top => { + rect.max.y = anchor_rect.min.y - position.gap; + rect.min.y = rect.max.y - float_size.y; + } + + FloatSide::Bottom => { + rect.min.y = anchor_rect.max.y + position.gap; + rect.max.y = rect.min.y + float_size.y; + } + + FloatSide::Left => { + rect.max.x = anchor_rect.min.x - position.gap; + rect.min.x = rect.max.x - float_size.x; + } + + FloatSide::Right => { + rect.min.x = anchor_rect.max.x + position.gap; + rect.max.x = rect.min.x + float_size.x; + } + } + + // Position along secondary axis. + match position.align { + FloatAlign::Start => match position.side { + FloatSide::Top | FloatSide::Bottom => { + rect.min.x = anchor_rect.min.x; + rect.max.x = rect.min.x + target_width; + } + + FloatSide::Left | FloatSide::Right => { + rect.min.y = anchor_rect.min.y; + rect.max.y = rect.min.y + target_height; + } + }, + + FloatAlign::End => match position.side { + FloatSide::Top | FloatSide::Bottom => { + rect.max.x = anchor_rect.max.x; + rect.min.x = rect.max.x - target_width; + } + + FloatSide::Left | FloatSide::Right => { + rect.max.y = anchor_rect.max.y; + rect.min.y = rect.max.y - target_height; + } + }, + + FloatAlign::Center => match position.side { + FloatSide::Top | FloatSide::Bottom => { + rect.min.x = (anchor_rect.width() - target_width) * 0.5; + rect.max.x = rect.min.x + target_width; + } + + FloatSide::Left | FloatSide::Right => { + rect.min.y = (anchor_rect.width() - target_height) * 0.5; + rect.max.y = rect.min.y + target_height; + } + }, + } + + // Clip to window and see how much of the floating element is occluded. We can calculate + // how much was clipped by intersecting the rectangle against the window bounds, and + // then subtracting the area from the area of the unclipped rectangle. + let clipped_rect = rect.intersect(window_rect); + let occlusion = + rect.width() * rect.height() - clipped_rect.width() * clipped_rect.height(); + + // Find the position that has the least occlusion. + if occlusion < best_occluded { + best_occluded = occlusion; + best_rect = rect; + best_position = *position; + } + } + + if best_occluded < f32::MAX { + node.left = Val::Px(best_rect.min.x); + node.top = Val::Px(best_rect.min.y); + if best_position.stretch { + match best_position.side { + FloatSide::Top | FloatSide::Bottom => { + node.min_width = Val::Px(best_rect.width()); + } + + FloatSide::Left | FloatSide::Right => { + node.min_height = Val::Px(best_rect.height()); + } + } + } + } + } +} + +/// Plugin that adds systems for the [`Floating`] component. +pub struct FloatingPlugin; + +impl Plugin for FloatingPlugin { + fn build(&self, app: &mut App) { + app.add_systems(PostUpdate, position_floating); + } +} diff --git a/crates/bevy_core_widgets/src/lib.rs b/crates/bevy_core_widgets/src/lib.rs index 2a3fc1ac097cd..c67905aa140f8 100644 --- a/crates/bevy_core_widgets/src/lib.rs +++ b/crates/bevy_core_widgets/src/lib.rs @@ -17,9 +17,12 @@ mod callback; mod core_button; mod core_checkbox; +mod core_menu; mod core_radio; mod core_scrollbar; mod core_slider; +pub mod floating; +pub mod portal; use bevy_app::{App, Plugin}; @@ -36,6 +39,8 @@ pub use core_slider::{ SliderRange, SliderStep, SliderValue, TrackClick, }; +use crate::floating::FloatingPlugin; + /// A plugin that registers the observers for all of the core widgets. If you don't want to /// use all of the widgets, you can import the individual widget plugins instead. pub struct CoreWidgetsPlugin; @@ -43,6 +48,7 @@ pub struct CoreWidgetsPlugin; impl Plugin for CoreWidgetsPlugin { fn build(&self, app: &mut App) { app.add_plugins(( + FloatingPlugin, CoreButtonPlugin, CoreCheckboxPlugin, CoreRadioGroupPlugin, diff --git a/crates/bevy_core_widgets/src/portal.rs b/crates/bevy_core_widgets/src/portal.rs new file mode 100644 index 0000000000000..50d6a55d293bf --- /dev/null +++ b/crates/bevy_core_widgets/src/portal.rs @@ -0,0 +1,34 @@ +//! Relationships for defining "portal children", where the term "portal" refers to a mechanism +//! whereby a logical child node can be physically located at a different point in the hierarchy. +//! The "portal" represents a logical connection between the child and it's parent which is not +//! a normal child relationship. + +use bevy_ecs::{component::Component, entity::Entity, hierarchy::ChildOf, query::QueryData}; + +/// Defines the portal child relationship. For purposes of despawning, a portal child behaves +/// as if it's a real child. However, for purpose of rendering and layout, a portal child behaves +/// as if it's a root element. Certain events can also bubble via the portal relationship. +#[derive(Component, Clone, PartialEq, Eq, Debug)] +#[relationship(relationship_target = PortalChildren)] +pub struct PortalChildOf(#[entities] pub Entity); + +impl PortalChildOf { + /// The parent entity of this child entity. + #[inline] + pub fn parent(&self) -> Entity { + self.0 + } +} + +/// Tracks the portal children of this entity. +#[derive(Component, Default, Debug, PartialEq, Eq)] +#[relationship_target(relationship = PortalChildOf, linked_spawn)] +pub struct PortalChildren(Vec); + +/// A traversal that uses either the [`ChildOf`] or [`PortalChildOf`] relationship. If the +/// entity has both relations, the latter takes precedence. +#[derive(QueryData)] +pub struct PortalTraversal { + pub(crate) child_of: Option<&'static ChildOf>, + pub(crate) portal_child_of: Option<&'static PortalChildOf>, +} From 8ed8666cbfe707db778390b5cba9804bbdc309a1 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 25 Jun 2025 14:30:38 -0700 Subject: [PATCH 2/3] More poking at `Floating`. --- crates/bevy_core_widgets/src/core_menu.rs | 13 ++-- crates/bevy_core_widgets/src/floating.rs | 26 +++++-- crates/bevy_core_widgets/src/portal.rs | 18 +++-- examples/ui/core_widgets.rs | 86 +++++++++++++++++++++++ 4 files changed, 127 insertions(+), 16 deletions(-) diff --git a/crates/bevy_core_widgets/src/core_menu.rs b/crates/bevy_core_widgets/src/core_menu.rs index 67d88716720ff..885f2081e2a08 100644 --- a/crates/bevy_core_widgets/src/core_menu.rs +++ b/crates/bevy_core_widgets/src/core_menu.rs @@ -17,7 +17,8 @@ use crate::portal::{PortalTraversal, PortalTraversalItem}; /// /// Focus navigation: the menu may be part of a composite of multiple menus such as a menu bar. /// This means that depending on direction, focus movement may move to the next menu item, or -/// the next menu. +/// the next menu. This also means that different events will often be handled at different +/// levels of the hierarchy - some being handled by the popup, and some by the popup's owner. #[derive(Event, EntityEvent, Clone)] pub enum MenuEvent { /// Indicates we want to open the menu, if it is not already open. @@ -28,6 +29,10 @@ pub enum MenuEvent { /// Move the input focs to the parent element. This usually happens as the menu is closing, /// although will not happen if the close was a result of clicking on the background. FocusParent, + /// Move the input focus to the first child in the parent's hierarchy (Home). + FocusFirst, + /// Move the input focus to the last child in the parent's hierarchy (End). + FocusLast, /// Move the input focus to the previous child in the parent's hierarchy (Shift-Tab). FocusPrev, /// Move the input focus to the next child in the parent's hierarchy (Tab). @@ -63,16 +68,16 @@ impl Traversal for PortalTraversal { } } -/// Component that defines a popup menu +/// Component that defines a popup menu container. #[derive(Component, Debug)] #[require(AccessibilityNode(accesskit::Node::new(Role::MenuListPopup)))] pub struct CoreMenuPopup; -/// Component that defines a menu item +/// Component that defines a menu item. #[derive(Component, Debug)] #[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))] pub struct CoreMenuItem { /// Optional system to run when the menu item is clicked, or when the Enter or Space key - /// is pressed while the button is focused. + /// is pressed while the item is focused. pub on_click: Option, } diff --git a/crates/bevy_core_widgets/src/floating.rs b/crates/bevy_core_widgets/src/floating.rs index ce228b38de189..6f6838842a035 100644 --- a/crates/bevy_core_widgets/src/floating.rs +++ b/crates/bevy_core_widgets/src/floating.rs @@ -1,9 +1,14 @@ //! Framework for positioning of popups, tooltips, and other floating UI elements. -use bevy_app::{App, Plugin, PostUpdate}; -use bevy_ecs::{component::Component, entity::Entity, query::Without, system::Query}; +use bevy_app::{App, Plugin, PreUpdate}; +use bevy_ecs::{ + component::Component, entity::Entity, query::Without, schedule::IntoScheduleConfigs, + system::Query, +}; use bevy_math::{Rect, Vec2}; -use bevy_ui::{ComputedNode, ComputedNodeTarget, Node, UiGlobalTransform, Val}; +use bevy_ui::{ + ComputedNode, ComputedNodeTarget, Node, PositionType, UiGlobalTransform, UiSystems, Val, +}; /// Which side of the anchor element the floating element should be placed. #[derive(Debug, Default, Clone, Copy, PartialEq)] @@ -102,6 +107,11 @@ fn position_floating( q_anchor: Query<(&ComputedNode, &UiGlobalTransform), Without>, ) { for (mut node, computed_node, computed_target, floating) in q_float.iter_mut() { + // Logical size isn't set initially, ignore until it is. + if computed_target.logical_size().length_squared() == 0.0 { + continue; + } + // A rectangle which represents the area of the window. let window_rect = Rect { min: Vec2::ZERO, @@ -114,7 +124,10 @@ fn position_floating( let Ok((anchor_node, anchor_transform)) = q_anchor.get(anchor_entity) else { continue; }; - Rect::from_center_size(anchor_transform.translation, anchor_node.size()) + Rect::from_center_size( + anchor_transform.translation * anchor_node.inverse_scale_factor, + anchor_node.size() * anchor_node.inverse_scale_factor, + ) } FloatAnchor::Rect(rect) => rect, }; @@ -125,7 +138,7 @@ fn position_floating( // Loop through all the potential positions and find a good one. for position in &floating.positions { - let float_size = computed_node.size(); + let float_size = computed_node.size() * computed_node.inverse_scale_factor; let mut rect = Rect::default(); // Taraget width and height depends on whether 'stretch' is true. @@ -225,6 +238,7 @@ fn position_floating( if best_occluded < f32::MAX { node.left = Val::Px(best_rect.min.x); node.top = Val::Px(best_rect.min.y); + node.position_type = PositionType::Absolute; if best_position.stretch { match best_position.side { FloatSide::Top | FloatSide::Bottom => { @@ -245,6 +259,6 @@ pub struct FloatingPlugin; impl Plugin for FloatingPlugin { fn build(&self, app: &mut App) { - app.add_systems(PostUpdate, position_floating); + app.add_systems(PreUpdate, position_floating.in_set(UiSystems::Prepare)); } } diff --git a/crates/bevy_core_widgets/src/portal.rs b/crates/bevy_core_widgets/src/portal.rs index 50d6a55d293bf..4c5a8164b41ab 100644 --- a/crates/bevy_core_widgets/src/portal.rs +++ b/crates/bevy_core_widgets/src/portal.rs @@ -1,13 +1,19 @@ -//! Relationships for defining "portal children", where the term "portal" refers to a mechanism -//! whereby a logical child node can be physically located at a different point in the hierarchy. -//! The "portal" represents a logical connection between the child and it's parent which is not -//! a normal child relationship. +//! Relationships for defining "portal children". +//! +//! The term "portal" is commonly used in web user interface libraries to mean a mechanism whereby a +//! parent element can have a logical child which is physically present elsewhere in the hierarchy. +//! In this case, it means that for rendering and layout purposes, the child acts as a root node, +//! but for purposes of event bubbling and ownership, it acts as a child. +//! +//! This is typically used for UI elements such as menus and dialogs which need to calculate their +//! positions in window coordinates, despite being owned by UI elements nested deep within the +//! hierarchy. use bevy_ecs::{component::Component, entity::Entity, hierarchy::ChildOf, query::QueryData}; /// Defines the portal child relationship. For purposes of despawning, a portal child behaves /// as if it's a real child. However, for purpose of rendering and layout, a portal child behaves -/// as if it's a root element. Certain events can also bubble via the portal relationship. +/// as if it's a root element. Certain events can also bubble through the portal relationship. #[derive(Component, Clone, PartialEq, Eq, Debug)] #[relationship(relationship_target = PortalChildren)] pub struct PortalChildOf(#[entities] pub Entity); @@ -25,7 +31,7 @@ impl PortalChildOf { #[relationship_target(relationship = PortalChildOf, linked_spawn)] pub struct PortalChildren(Vec); -/// A traversal that uses either the [`ChildOf`] or [`PortalChildOf`] relationship. If the +/// A traversal algorithm that uses either the [`ChildOf`] or [`PortalChildOf`] relationship. If the /// entity has both relations, the latter takes precedence. #[derive(QueryData)] pub struct PortalTraversal { diff --git a/examples/ui/core_widgets.rs b/examples/ui/core_widgets.rs index 86aaa820f8e45..0398669e244ea 100644 --- a/examples/ui/core_widgets.rs +++ b/examples/ui/core_widgets.rs @@ -78,6 +78,10 @@ struct DemoCheckbox; #[derive(Component, Default)] struct DemoRadio(TrackClick); +/// Menuy button styling marker +#[derive(Component)] +struct DemoMenuButton; + /// A struct to hold the state of various widgets shown in the demo. /// /// While it is possible to use the widget's own state components as the source of truth, @@ -132,6 +136,8 @@ fn setup(mut commands: Commands, assets: Res) { }, ); + let on_open_menu = commands.register_system(spawn_popup); + // System to update a resource when the radio group changes. let on_change_radio = commands.register_system( |value: In, @@ -213,6 +219,49 @@ fn button(asset_server: &AssetServer, on_click: Callback) -> impl Bundle { ) } +fn menu_button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle { + ( + Node { + width: Val::Px(200.0), + height: Val::Px(65.0), + border: UiRect::all(Val::Px(5.0)), + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + padding: UiRect::axes(Val::Px(16.0), Val::Px(0.0)), + ..default() + }, + DemoMenuButton, + CoreButton { + on_click: Callback::System(on_click), + }, + Hovered::default(), + TabIndex(0), + BorderColor::all(Color::BLACK), + BorderRadius::all(Val::Px(5.0)), + BackgroundColor(NORMAL_BUTTON), + children![ + ( + Text::new("Menu"), + TextFont { + font: asset_server.load("fonts/FiraSans-Bold.ttf"), + font_size: 33.0, + ..default() + }, + TextColor(Color::srgb(0.9, 0.9, 0.9)), + TextShadow::default(), + ), + ( + Node { + width: Val::Px(12.0), + height: Val::Px(12.0), + ..default() + }, + BackgroundColor(GRAY.into()), + ) + ], + ) +} + fn update_button_style( mut buttons: Query< ( @@ -739,6 +788,43 @@ fn radio(asset_server: &AssetServer, value: TrackClick, caption: &str) -> impl B ) } +fn spawn_popup(menu: Query>, mut commands: Commands) { + let Ok(anchor) = menu.single() else { + return; + }; + commands.entity(anchor).insert(PortalChildren::spawn_one(( + Node { + min_height: Val::Px(100.), + min_width: Val::Px(100.), + border: UiRect::all(Val::Px(2.0)), + position_type: PositionType::Absolute, + left: Val::Px(100.), + ..default() + }, + BorderColor::all(GREEN.into()), + BackgroundColor(GRAY.into()), + ZIndex(100), + Floating { + anchor: FloatAnchor::Node(anchor), + positions: vec![ + FloatPosition { + side: FloatSide::Bottom, + align: FloatAlign::Start, + gap: 2.0, + ..default() + }, + FloatPosition { + side: FloatSide::Top, + align: FloatAlign::Start, + gap: 2.0, + ..default() + }, + ], + }, + ))); + info!("Open menu"); +} + fn toggle_disabled( input: Res>, mut interaction_query: Query< From 3c4fdd997c54d7d86e8bd1beef5c480c04f22bd7 Mon Sep 17 00:00:00 2001 From: Talin Date: Sun, 6 Jul 2025 10:59:11 -0700 Subject: [PATCH 3/3] Renamed Floating to Popover; work on menus. --- crates/bevy_core_widgets/Cargo.toml | 1 + crates/bevy_core_widgets/src/core_menu.rs | 212 ++++++++++++-- crates/bevy_core_widgets/src/floating.rs | 264 ------------------ crates/bevy_core_widgets/src/lib.rs | 9 +- crates/bevy_core_widgets/src/popover.rs | 246 ++++++++++++++++ crates/bevy_core_widgets/src/portal.rs | 40 --- crates/bevy_input_focus/src/lib.rs | 2 +- crates/bevy_input_focus/src/tab_navigation.rs | 45 ++- crates/bevy_math/src/rects/rect.rs | 32 +++ examples/ui/core_widgets.rs | 77 ++--- 10 files changed, 547 insertions(+), 381 deletions(-) delete mode 100644 crates/bevy_core_widgets/src/floating.rs create mode 100644 crates/bevy_core_widgets/src/popover.rs delete mode 100644 crates/bevy_core_widgets/src/portal.rs diff --git a/crates/bevy_core_widgets/Cargo.toml b/crates/bevy_core_widgets/Cargo.toml index 57e2968e222b5..766748632cb42 100644 --- a/crates/bevy_core_widgets/Cargo.toml +++ b/crates/bevy_core_widgets/Cargo.toml @@ -23,6 +23,7 @@ bevy_transform = { path = "../bevy_transform", version = "0.17.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.17.0-dev", features = [ "bevy_ui_picking_backend", ] } +bevy_window = { path = "../bevy_window", version = "0.17.0-dev" } # other accesskit = "0.19" diff --git a/crates/bevy_core_widgets/src/core_menu.rs b/crates/bevy_core_widgets/src/core_menu.rs index 885f2081e2a08..1f3866b17c20b 100644 --- a/crates/bevy_core_widgets/src/core_menu.rs +++ b/crates/bevy_core_widgets/src/core_menu.rs @@ -2,15 +2,30 @@ use accesskit::Role; use bevy_a11y::AccessibilityNode; +use bevy_app::{App, Plugin}; use bevy_ecs::{ component::Component, entity::Entity, event::{EntityEvent, Event}, - system::SystemId, - traversal::Traversal, + hierarchy::ChildOf, + lifecycle::Add, + observer::On, + query::{Has, With}, + system::{Commands, Query, ResMut}, }; +use bevy_input::{ + keyboard::{KeyCode, KeyboardInput}, + ButtonState, +}; +use bevy_input_focus::{ + tab_navigation::{NavAction, TabGroup, TabNavigation}, + AcquireFocus, FocusedInput, InputFocus, +}; +use bevy_log::warn; +use bevy_ui::InteractionDisabled; +use bevy_window::PrimaryWindow; -use crate::portal::{PortalTraversal, PortalTraversalItem}; +use crate::{Callback, Notify}; /// Event use to control the state of the open menu. This bubbles upwards from the menu items /// and the menu container, through the portal relation, and to the menu owner entity. @@ -26,9 +41,10 @@ pub enum MenuEvent { /// Close the menu and despawn it. Despawning may not happen immediately if there is a closing /// transition animation. Close, - /// Move the input focs to the parent element. This usually happens as the menu is closing, - /// although will not happen if the close was a result of clicking on the background. - FocusParent, + /// Close the entire menu stack. The boolean argument indicates whether we want to retain + /// focus on the menu owner (the menu button). Whether this is true will depend on the reason + /// for closing: a click on the background should not restore focus to the button. + CloseAll(bool), /// Move the input focus to the first child in the parent's hierarchy (Home). FocusFirst, /// Move the input focus to the last child in the parent's hierarchy (End). @@ -47,37 +63,173 @@ pub enum MenuEvent { FocusRight, } -impl Traversal for PortalTraversal { - fn traverse(item: Self::Item<'_, '_>, _event: &MenuEvent) -> Option { - let PortalTraversalItem { - child_of, - portal_child_of, - } = item; - - // Send event to portal parent, if it has one. - if let Some(portal_child_of) = portal_child_of { - return Some(portal_child_of.parent()); - }; - - // Send event to parent, if it has one. - if let Some(child_of) = child_of { - return Some(child_of.parent()); - }; - - None - } -} - /// Component that defines a popup menu container. +/// +/// A popup menu *must* contain at least one focusable entity. The first such entity will acquire +/// focus when the popup is spawned; arrow keys can be used to navigate between menu items. If no +/// descendant of the menu has focus, the menu will automatically close. This rule has several +/// consequences: +/// +/// * Clicking on another widget or empty space outside the menu will cause the menu to close. +/// * Two menus cannot be displayed at the same time unless one is an ancestor of the other. #[derive(Component, Debug)] -#[require(AccessibilityNode(accesskit::Node::new(Role::MenuListPopup)))] +#[require( + AccessibilityNode(accesskit::Node::new(Role::MenuListPopup)), + TabGroup::modal() +)] pub struct CoreMenuPopup; /// Component that defines a menu item. #[derive(Component, Debug)] #[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))] pub struct CoreMenuItem { - /// Optional system to run when the menu item is clicked, or when the Enter or Space key + /// Callback to invoke when the menu item is clicked, or when the `Enter` or `Space` key /// is pressed while the item is focused. - pub on_click: Option, + pub on_activate: Callback, +} + +fn menu_on_spawn( + ev: On, + mut focus: ResMut, + tab_navigation: TabNavigation, +) { + // When a menu is spawned, attempt to find the first focusable menu item, and set focus + // to it. + if let Ok(next) = tab_navigation.initialize(ev.target(), NavAction::First) { + focus.0 = Some(next); + } else { + warn!("No focusable menu items for popup menu: {}", ev.target()); + } +} + +fn menu_on_key_event( + mut ev: On>, + q_item: Query<(&CoreMenuItem, Has)>, + q_menu: Query<&CoreMenuPopup>, + mut commands: Commands, +) { + if let Ok((menu_item, disabled)) = q_item.get(ev.target()) { + if !disabled { + let event = &ev.event().input; + if !event.repeat && event.state == ButtonState::Pressed { + match event.key_code { + // Activate the item and close the popup + KeyCode::Enter | KeyCode::Space => { + ev.propagate(false); + commands.notify(&menu_item.on_activate); + commands.trigger_targets(MenuEvent::CloseAll(true), ev.target()); + } + + _ => (), + } + } + } + } else if let Ok(menu) = q_menu.get(ev.target()) { + let event = &ev.event().input; + if !event.repeat && event.state == ButtonState::Pressed { + match event.key_code { + // Close the popup + KeyCode::Escape => { + ev.propagate(false); + commands.trigger_targets(MenuEvent::CloseAll(true), ev.target()); + } + + // Focus the adjacent item in the up direction + KeyCode::ArrowUp => { + ev.propagate(false); + commands.trigger_targets(MenuEvent::FocusUp, ev.target()); + } + + // Focus the adjacent item in the down direction + KeyCode::ArrowDown => { + ev.propagate(false); + commands.trigger_targets(MenuEvent::FocusDown, ev.target()); + } + + // Focus the adjacent item in the left direction + KeyCode::ArrowLeft => { + ev.propagate(false); + commands.trigger_targets(MenuEvent::FocusLeft, ev.target()); + } + + // Focus the adjacent item in the right direction + KeyCode::ArrowRight => { + ev.propagate(false); + commands.trigger_targets(MenuEvent::FocusRight, ev.target()); + } + + // Focus the first item + KeyCode::Home => { + ev.propagate(false); + commands.trigger_targets(MenuEvent::FocusFirst, ev.target()); + } + + // Focus the last item + KeyCode::End => { + ev.propagate(false); + commands.trigger_targets(MenuEvent::FocusLast, ev.target()); + } + + _ => (), + } + } + } +} + +fn menu_on_menu_event( + mut ev: On, + q_popup: Query<(), With>, + q_parent: Query<&ChildOf>, + windows: Query>, + mut commands: Commands, +) { + if q_popup.contains(ev.target()) { + match ev.event() { + MenuEvent::Open => todo!(), + MenuEvent::Close => { + ev.propagate(false); + commands.entity(ev.target()).despawn(); + } + MenuEvent::CloseAll(retain_focus) => { + // For CloseAll, find the root menu popup and despawn it + // This will propagate the despawn to all child popups + let root_menu = q_parent + .iter_ancestors(ev.target()) + .filter(|&e| q_popup.contains(e)) + .last() + .unwrap_or(ev.target()); + + // Get the parent of the root menu and trigger an AcquireFocus event. + if let Ok(root_parent) = q_parent.get(root_menu) { + if *retain_focus { + if let Ok(window) = windows.single() { + commands.trigger_targets(AcquireFocus { window }, root_parent.parent()); + } + } + } + + ev.propagate(false); + commands.entity(root_menu).despawn(); + } + MenuEvent::FocusFirst => todo!(), + MenuEvent::FocusLast => todo!(), + MenuEvent::FocusPrev => todo!(), + MenuEvent::FocusNext => todo!(), + MenuEvent::FocusUp => todo!(), + MenuEvent::FocusDown => todo!(), + MenuEvent::FocusLeft => todo!(), + MenuEvent::FocusRight => todo!(), + } + } +} + +/// Plugin that adds the observers for the [`CoreButton`] widget. +pub struct CoreMenuPlugin; + +impl Plugin for CoreMenuPlugin { + fn build(&self, app: &mut App) { + app.add_observer(menu_on_spawn) + .add_observer(menu_on_key_event) + .add_observer(menu_on_menu_event); + } } diff --git a/crates/bevy_core_widgets/src/floating.rs b/crates/bevy_core_widgets/src/floating.rs deleted file mode 100644 index 6f6838842a035..0000000000000 --- a/crates/bevy_core_widgets/src/floating.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! Framework for positioning of popups, tooltips, and other floating UI elements. - -use bevy_app::{App, Plugin, PreUpdate}; -use bevy_ecs::{ - component::Component, entity::Entity, query::Without, schedule::IntoScheduleConfigs, - system::Query, -}; -use bevy_math::{Rect, Vec2}; -use bevy_ui::{ - ComputedNode, ComputedNodeTarget, Node, PositionType, UiGlobalTransform, UiSystems, Val, -}; - -/// Which side of the anchor element the floating element should be placed. -#[derive(Debug, Default, Clone, Copy, PartialEq)] -pub enum FloatSide { - /// The floating element should be placed above the anchor. - Top, - /// The floating element should be placed below the anchor. - #[default] - Bottom, - /// The floating element should be placed to the left of the anchor. - Left, - /// The floating element should be placed to the right of the anchor. - Right, -} - -impl FloatSide { - /// Returns the side that is the mirror image of this side. - pub fn mirror(&self) -> Self { - match self { - FloatSide::Top => FloatSide::Bottom, - FloatSide::Bottom => FloatSide::Top, - FloatSide::Left => FloatSide::Right, - FloatSide::Right => FloatSide::Left, - } - } -} - -/// How the floating element should be aligned to the anchor element. The alignment will be along an -/// axis that is perpendicular to the direction of the float side. So for example, if the popup is -/// positioned below the anchor, then the [`FloatAlign`] variant controls the horizontal aligment of -/// the popup. -#[derive(Debug, Default, Clone, Copy, PartialEq)] -pub enum FloatAlign { - /// The starting edge of the floating element should be aligned to the starting edge of the - /// anchor. - #[default] - Start, - /// The ending edge of the floating element should be aligned to the ending edge of the anchor. - End, - /// The center of the floating element should be aligned to the center of the anchor. - Center, -} - -/// Indicates a possible position of a floating element relative to an anchor element. You can -/// specify multiple possible positions; the positioning code will check to see if there is -/// sufficient space to display the popup without clipping. If any position has sufficient room, -/// it will pick the first one; if there are none, then it will pick the least bad one. -#[derive(Debug, Default, Clone, Copy, PartialEq)] -pub struct FloatPosition { - /// The side of the anchor the floating element should be placed. - pub side: FloatSide, - - /// How the floating element should be aligned to the anchor. - pub align: FloatAlign, - - /// If true, the floating element will be at least as large as the anchor on the adjacent - /// side. - pub stretch: bool, - - /// The size of the gap between the anchor and the floating element. This will offset the - /// float along the direction of the [`FloatSide`]. - pub gap: f32, -} - -/// Defines the anchor position which the floating element is positioned relative to. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum FloatAnchor { - /// The anchor is an entity with a UI [`Node`] component. - Node(Entity), - /// The anchor is an arbitrary rectangle in window coordinates. - Rect(Rect), -} - -/// Component which is inserted into a floating element to make it dynamically position relative to -/// an anchor element. -#[derive(Component, PartialEq)] -pub struct Floating { - /// The entity that this floating element is anchored to. - pub anchor: FloatAnchor, - - /// List of potential positions for the floating element relative to the anchor. - pub positions: Vec, -} - -impl Clone for Floating { - fn clone(&self) -> Self { - Self { - anchor: self.anchor, - positions: self.positions.clone(), - } - } -} - -fn position_floating( - mut q_float: Query<(&mut Node, &ComputedNode, &ComputedNodeTarget, &Floating)>, - q_anchor: Query<(&ComputedNode, &UiGlobalTransform), Without>, -) { - for (mut node, computed_node, computed_target, floating) in q_float.iter_mut() { - // Logical size isn't set initially, ignore until it is. - if computed_target.logical_size().length_squared() == 0.0 { - continue; - } - - // A rectangle which represents the area of the window. - let window_rect = Rect { - min: Vec2::ZERO, - max: computed_target.logical_size(), - }; - - // Compute the anchor rectangle. - let anchor_rect: Rect = match floating.anchor { - FloatAnchor::Node(anchor_entity) => { - let Ok((anchor_node, anchor_transform)) = q_anchor.get(anchor_entity) else { - continue; - }; - Rect::from_center_size( - anchor_transform.translation * anchor_node.inverse_scale_factor, - anchor_node.size() * anchor_node.inverse_scale_factor, - ) - } - FloatAnchor::Rect(rect) => rect, - }; - - let mut best_occluded = f32::MAX; - let mut best_rect = Rect::default(); - let mut best_position: FloatPosition = Default::default(); - - // Loop through all the potential positions and find a good one. - for position in &floating.positions { - let float_size = computed_node.size() * computed_node.inverse_scale_factor; - let mut rect = Rect::default(); - - // Taraget width and height depends on whether 'stretch' is true. - let target_width = if position.stretch && position.side == FloatSide::Top - || position.side == FloatSide::Bottom - { - float_size.x.max(anchor_rect.width()) - } else { - float_size.x - }; - - let target_height = if position.stretch && position.side == FloatSide::Left - || position.side == FloatSide::Right - { - float_size.y.max(anchor_rect.height()) - } else { - float_size.y - }; - - // Position along main axis. - match position.side { - FloatSide::Top => { - rect.max.y = anchor_rect.min.y - position.gap; - rect.min.y = rect.max.y - float_size.y; - } - - FloatSide::Bottom => { - rect.min.y = anchor_rect.max.y + position.gap; - rect.max.y = rect.min.y + float_size.y; - } - - FloatSide::Left => { - rect.max.x = anchor_rect.min.x - position.gap; - rect.min.x = rect.max.x - float_size.x; - } - - FloatSide::Right => { - rect.min.x = anchor_rect.max.x + position.gap; - rect.max.x = rect.min.x + float_size.x; - } - } - - // Position along secondary axis. - match position.align { - FloatAlign::Start => match position.side { - FloatSide::Top | FloatSide::Bottom => { - rect.min.x = anchor_rect.min.x; - rect.max.x = rect.min.x + target_width; - } - - FloatSide::Left | FloatSide::Right => { - rect.min.y = anchor_rect.min.y; - rect.max.y = rect.min.y + target_height; - } - }, - - FloatAlign::End => match position.side { - FloatSide::Top | FloatSide::Bottom => { - rect.max.x = anchor_rect.max.x; - rect.min.x = rect.max.x - target_width; - } - - FloatSide::Left | FloatSide::Right => { - rect.max.y = anchor_rect.max.y; - rect.min.y = rect.max.y - target_height; - } - }, - - FloatAlign::Center => match position.side { - FloatSide::Top | FloatSide::Bottom => { - rect.min.x = (anchor_rect.width() - target_width) * 0.5; - rect.max.x = rect.min.x + target_width; - } - - FloatSide::Left | FloatSide::Right => { - rect.min.y = (anchor_rect.width() - target_height) * 0.5; - rect.max.y = rect.min.y + target_height; - } - }, - } - - // Clip to window and see how much of the floating element is occluded. We can calculate - // how much was clipped by intersecting the rectangle against the window bounds, and - // then subtracting the area from the area of the unclipped rectangle. - let clipped_rect = rect.intersect(window_rect); - let occlusion = - rect.width() * rect.height() - clipped_rect.width() * clipped_rect.height(); - - // Find the position that has the least occlusion. - if occlusion < best_occluded { - best_occluded = occlusion; - best_rect = rect; - best_position = *position; - } - } - - if best_occluded < f32::MAX { - node.left = Val::Px(best_rect.min.x); - node.top = Val::Px(best_rect.min.y); - node.position_type = PositionType::Absolute; - if best_position.stretch { - match best_position.side { - FloatSide::Top | FloatSide::Bottom => { - node.min_width = Val::Px(best_rect.width()); - } - - FloatSide::Left | FloatSide::Right => { - node.min_height = Val::Px(best_rect.height()); - } - } - } - } - } -} - -/// Plugin that adds systems for the [`Floating`] component. -pub struct FloatingPlugin; - -impl Plugin for FloatingPlugin { - fn build(&self, app: &mut App) { - app.add_systems(PreUpdate, position_floating.in_set(UiSystems::Prepare)); - } -} diff --git a/crates/bevy_core_widgets/src/lib.rs b/crates/bevy_core_widgets/src/lib.rs index c67905aa140f8..6e41437f548f2 100644 --- a/crates/bevy_core_widgets/src/lib.rs +++ b/crates/bevy_core_widgets/src/lib.rs @@ -21,14 +21,14 @@ mod core_menu; mod core_radio; mod core_scrollbar; mod core_slider; -pub mod floating; -pub mod portal; +pub mod popover; use bevy_app::{App, Plugin}; pub use callback::{Callback, Notify}; pub use core_button::{CoreButton, CoreButtonPlugin}; pub use core_checkbox::{CoreCheckbox, CoreCheckboxPlugin, SetChecked, ToggleChecked}; +pub use core_menu::{CoreMenuItem, CoreMenuPlugin, CoreMenuPopup}; pub use core_radio::{CoreRadio, CoreRadioGroup, CoreRadioGroupPlugin}; pub use core_scrollbar::{ ControlOrientation, CoreScrollbar, CoreScrollbarDragState, CoreScrollbarPlugin, @@ -39,7 +39,7 @@ pub use core_slider::{ SliderRange, SliderStep, SliderValue, TrackClick, }; -use crate::floating::FloatingPlugin; +use crate::popover::PopoverPlugin; /// A plugin that registers the observers for all of the core widgets. If you don't want to /// use all of the widgets, you can import the individual widget plugins instead. @@ -48,9 +48,10 @@ pub struct CoreWidgetsPlugin; impl Plugin for CoreWidgetsPlugin { fn build(&self, app: &mut App) { app.add_plugins(( - FloatingPlugin, + PopoverPlugin, CoreButtonPlugin, CoreCheckboxPlugin, + CoreMenuPlugin, CoreRadioGroupPlugin, CoreScrollbarPlugin, CoreSliderPlugin, diff --git a/crates/bevy_core_widgets/src/popover.rs b/crates/bevy_core_widgets/src/popover.rs new file mode 100644 index 0000000000000..9bfcb5ff1ae8f --- /dev/null +++ b/crates/bevy_core_widgets/src/popover.rs @@ -0,0 +1,246 @@ +//! Framework for positioning of popups, tooltips, and other popover UI elements. + +use bevy_app::{App, Plugin, PreUpdate}; +use bevy_ecs::{ + change_detection::DetectChangesMut, component::Component, hierarchy::ChildOf, query::Without, + schedule::IntoScheduleConfigs, system::Query, +}; +use bevy_math::{Rect, Vec2}; +use bevy_render::view::Visibility; +use bevy_ui::{ + ComputedNode, ComputedNodeTarget, Node, PositionType, UiGlobalTransform, UiSystems, Val, +}; + +/// Which side of the parent element the popover element should be placed. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum PopoverSide { + /// The popover element should be placed above the parent. + Top, + /// The popover element should be placed below the parent. + #[default] + Bottom, + /// The popover element should be placed to the left of the parent. + Left, + /// The popover element should be placed to the right of the parent. + Right, +} + +impl PopoverSide { + /// Returns the side that is the mirror image of this side. + pub fn mirror(&self) -> Self { + match self { + PopoverSide::Top => PopoverSide::Bottom, + PopoverSide::Bottom => PopoverSide::Top, + PopoverSide::Left => PopoverSide::Right, + PopoverSide::Right => PopoverSide::Left, + } + } +} + +/// How the popover element should be aligned to the parent element. The alignment will be along an +/// axis that is perpendicular to the direction of the popover side. So for example, if the popup is +/// positioned below the parent, then the [`PopoverAlign`] variant controls the horizontal aligment +/// of the popup. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub enum PopoverAlign { + /// The starting edge of the popover element should be aligned to the starting edge of the + /// parent. + #[default] + Start, + /// The ending edge of the popover element should be aligned to the ending edge of the parent. + End, + /// The center of the popover element should be aligned to the center of the parent. + Center, +} + +/// Indicates a possible position of a popover element relative to it's parent. You can +/// specify multiple possible positions; the positioning code will check to see if there is +/// sufficient space to display the popup without clipping. If any position has sufficient room, +/// it will pick the first one; if there are none, then it will pick the least bad one. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct PopoverPlacement { + /// The side of the parent entity where the popover element should be placed. + pub side: PopoverSide, + + /// How the popover element should be aligned to the parent entity. + pub align: PopoverAlign, + + /// The size of the gap between the parent and the popover element, in logical pixels. This will + /// offset the popover along the direction of [`side`]. + pub gap: f32, +} + +/// Component which is inserted into a popover element to make it dynamically position relative to +/// an parent element. +#[derive(Component, PartialEq)] +pub struct Popover { + /// List of potential positions for the popover element relative to the parent. + pub positions: Vec, +} + +impl Clone for Popover { + fn clone(&self) -> Self { + Self { + positions: self.positions.clone(), + } + } +} + +fn position_popover( + mut q_popover: Query<( + &mut Node, + &mut Visibility, + &ComputedNode, + &ComputedNodeTarget, + &Popover, + &ChildOf, + )>, + q_parent: Query<(&ComputedNode, &UiGlobalTransform), Without>, +) { + for (mut node, mut visibility, computed_node, computed_target, popover, parent) in + q_popover.iter_mut() + { + // A rectangle which represents the area of the window. + let window_rect = Rect { + min: Vec2::ZERO, + max: computed_target.logical_size(), + }; + + // Logical size isn't set initially, ignore until it is. + if window_rect.area() <= 0.0 { + continue; + } + + // Compute the parent rectangle. + let Ok((parent_node, parent_transform)) = q_parent.get(parent.parent()) else { + continue; + }; + // Computed node size includes the border, but since absolute positioning doesn't include + // border we need to remove it from the calculations. + let parent_size = parent_node.size() + - Vec2::new( + parent_node.border.left + parent_node.border.right, + parent_node.border.top + parent_node.border.bottom, + ); + let parent_rect = Rect::from_center_size(parent_transform.translation, parent_size) + .scale(parent_node.inverse_scale_factor); + + let mut best_occluded = f32::MAX; + let mut best_rect = Rect::default(); + + // Loop through all the potential positions and find a good one. + for position in &popover.positions { + let popover_size = computed_node.size() * computed_node.inverse_scale_factor; + let mut rect = Rect::default(); + + let target_width = popover_size.x; + let target_height = popover_size.y; + + // Position along main axis. + match position.side { + PopoverSide::Top => { + rect.max.y = parent_rect.min.y - position.gap; + rect.min.y = rect.max.y - popover_size.y; + } + + PopoverSide::Bottom => { + rect.min.y = parent_rect.max.y + position.gap; + rect.max.y = rect.min.y + popover_size.y; + } + + PopoverSide::Left => { + rect.max.x = parent_rect.min.x - position.gap; + rect.min.x = rect.max.x - popover_size.x; + } + + PopoverSide::Right => { + rect.min.x = parent_rect.max.x + position.gap; + rect.max.x = rect.min.x + popover_size.x; + } + } + + // Position along secondary axis. + match position.align { + PopoverAlign::Start => match position.side { + PopoverSide::Top | PopoverSide::Bottom => { + rect.min.x = parent_rect.min.x; + rect.max.x = rect.min.x + target_width; + } + + PopoverSide::Left | PopoverSide::Right => { + rect.min.y = parent_rect.min.y; + rect.max.y = rect.min.y + target_height; + } + }, + + PopoverAlign::End => match position.side { + PopoverSide::Top | PopoverSide::Bottom => { + rect.max.x = parent_rect.max.x; + rect.min.x = rect.max.x - target_width; + } + + PopoverSide::Left | PopoverSide::Right => { + rect.max.y = parent_rect.max.y; + rect.min.y = rect.max.y - target_height; + } + }, + + PopoverAlign::Center => match position.side { + PopoverSide::Top | PopoverSide::Bottom => { + rect.min.x = (parent_rect.width() - target_width) * 0.5; + rect.max.x = rect.min.x + target_width; + } + + PopoverSide::Left | PopoverSide::Right => { + rect.min.y = (parent_rect.width() - target_height) * 0.5; + rect.max.y = rect.min.y + target_height; + } + }, + } + + // Clip to window and see how much of the popover element is occluded. We can calculate + // how much was clipped by intersecting the rectangle against the window bounds, and + // then subtracting the area from the area of the unclipped rectangle. + let clipped_rect = rect.intersect(window_rect); + let occlusion = rect.area() - clipped_rect.area(); + + // Find the position that has the least occlusion. + if occlusion < best_occluded { + best_occluded = occlusion; + best_rect = rect; + } + } + + // Update node properties, but only if they are different from before (to avoid setting + // change detection bit). + if best_occluded < f32::MAX { + let left = Val::Px(best_rect.min.x - parent_rect.min.x); + let top = Val::Px(best_rect.min.y - parent_rect.min.y); + visibility.set_if_neq(Visibility::Visible); + if node.left != left { + node.left = left; + } + if node.top != top { + node.top = top; + } + if node.bottom != Val::DEFAULT { + node.bottom = Val::DEFAULT; + } + if node.right != Val::DEFAULT { + node.right = Val::DEFAULT; + } + if node.position_type != PositionType::Absolute { + node.position_type = PositionType::Absolute; + } + } + } +} + +/// Plugin that adds systems for the [`Popover`] component. +pub struct PopoverPlugin; + +impl Plugin for PopoverPlugin { + fn build(&self, app: &mut App) { + app.add_systems(PreUpdate, position_popover.in_set(UiSystems::Prepare)); + } +} diff --git a/crates/bevy_core_widgets/src/portal.rs b/crates/bevy_core_widgets/src/portal.rs deleted file mode 100644 index 4c5a8164b41ab..0000000000000 --- a/crates/bevy_core_widgets/src/portal.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Relationships for defining "portal children". -//! -//! The term "portal" is commonly used in web user interface libraries to mean a mechanism whereby a -//! parent element can have a logical child which is physically present elsewhere in the hierarchy. -//! In this case, it means that for rendering and layout purposes, the child acts as a root node, -//! but for purposes of event bubbling and ownership, it acts as a child. -//! -//! This is typically used for UI elements such as menus and dialogs which need to calculate their -//! positions in window coordinates, despite being owned by UI elements nested deep within the -//! hierarchy. - -use bevy_ecs::{component::Component, entity::Entity, hierarchy::ChildOf, query::QueryData}; - -/// Defines the portal child relationship. For purposes of despawning, a portal child behaves -/// as if it's a real child. However, for purpose of rendering and layout, a portal child behaves -/// as if it's a root element. Certain events can also bubble through the portal relationship. -#[derive(Component, Clone, PartialEq, Eq, Debug)] -#[relationship(relationship_target = PortalChildren)] -pub struct PortalChildOf(#[entities] pub Entity); - -impl PortalChildOf { - /// The parent entity of this child entity. - #[inline] - pub fn parent(&self) -> Entity { - self.0 - } -} - -/// Tracks the portal children of this entity. -#[derive(Component, Default, Debug, PartialEq, Eq)] -#[relationship_target(relationship = PortalChildOf, linked_spawn)] -pub struct PortalChildren(Vec); - -/// A traversal algorithm that uses either the [`ChildOf`] or [`PortalChildOf`] relationship. If the -/// entity has both relations, the latter takes precedence. -#[derive(QueryData)] -pub struct PortalTraversal { - pub(crate) child_of: Option<&'static ChildOf>, - pub(crate) portal_child_of: Option<&'static PortalChildOf>, -} diff --git a/crates/bevy_input_focus/src/lib.rs b/crates/bevy_input_focus/src/lib.rs index df7690ef26fca..38e82ff8b2895 100644 --- a/crates/bevy_input_focus/src/lib.rs +++ b/crates/bevy_input_focus/src/lib.rs @@ -153,7 +153,7 @@ pub struct FocusedInput { #[entity_event(traversal = WindowTraversal, auto_propagate)] pub struct AcquireFocus { /// The primary window entity. - window: Entity, + pub window: Entity, } #[derive(QueryData)] diff --git a/crates/bevy_input_focus/src/tab_navigation.rs b/crates/bevy_input_focus/src/tab_navigation.rs index 6a8a24772da0a..e73a02fba9259 100644 --- a/crates/bevy_input_focus/src/tab_navigation.rs +++ b/crates/bevy_input_focus/src/tab_navigation.rs @@ -166,12 +166,12 @@ pub struct TabNavigation<'w, 's> { } impl TabNavigation<'_, '_> { - /// Navigate to the desired focusable entity. + /// Navigate to the desired focusable entity, relative to the current focused entity. /// /// Change the [`NavAction`] to navigate in a different direction. /// Focusable entities are determined by the presence of the [`TabIndex`] component. /// - /// If no focusable entities are found, then this function will return either the first + /// If there is no currently focused entity, then this function will return either the first /// or last focusable entity, depending on the direction of navigation. For example, if /// `action` is `Next` and no focusable entities are found, then this function will return /// the first focusable entity. @@ -198,13 +198,46 @@ impl TabNavigation<'_, '_> { }) }); + self.navigate_internal(focus.0, action, tabgroup) + } + + /// Initialize focus to a focusable child of a container, either the first or last + /// depending on [`NavAction`]. This assumes that the parent entity has a [`TabGroup`] + /// component. + /// + /// Focusable entities are determined by the presence of the [`TabIndex`] component. + pub fn initialize( + &self, + parent: Entity, + action: NavAction, + ) -> Result { + // If there are no tab groups, then there are no focusable entities. + if self.tabgroup_query.is_empty() { + return Err(TabNavigationError::NoTabGroups); + } + + // Look for the tab group on the parent entity. + match self.tabgroup_query.get(parent) { + Ok(tabgroup) => self.navigate_internal(None, action, Some((parent, tabgroup.1))), + Err(_) => Err(TabNavigationError::NoTabGroups), + } + } + + pub fn navigate_internal( + &self, + focus: Option, + action: NavAction, + tabgroup: Option<(Entity, &TabGroup)>, + ) -> Result { let navigation_result = self.navigate_in_group(tabgroup, focus, action); match navigation_result { Ok(entity) => { - if focus.0.is_some() && tabgroup.is_none() { + if let Some(previous_focus) = focus + && tabgroup.is_none() + { Err(TabNavigationError::NoTabGroupForCurrentFocus { - previous_focus: focus.0.unwrap(), + previous_focus, new_focus: entity, }) } else { @@ -218,7 +251,7 @@ impl TabNavigation<'_, '_> { fn navigate_in_group( &self, tabgroup: Option<(Entity, &TabGroup)>, - focus: &InputFocus, + focus: Option, action: NavAction, ) -> Result { // List of all focusable entities found. @@ -268,7 +301,7 @@ impl TabNavigation<'_, '_> { } }); - let index = focusable.iter().position(|e| Some(e.0) == focus.0); + let index = focusable.iter().position(|e| Some(e.0) == focus); let count = focusable.len(); let next = match (index, action) { (Some(idx), NavAction::Next) => (idx + 1).rem_euclid(count), diff --git a/crates/bevy_math/src/rects/rect.rs b/crates/bevy_math/src/rects/rect.rs index 92b7059945949..eda0510a76978 100644 --- a/crates/bevy_math/src/rects/rect.rs +++ b/crates/bevy_math/src/rects/rect.rs @@ -356,6 +356,38 @@ impl Rect { } } + /// Return the area of this rectangle. + /// + /// # Examples + /// + /// ``` + /// # use bevy_math::Rect; + /// let r = Rect::new(0., 0., 10., 10.); // w=10 h=10 + /// assert_eq!(r.area(), 100.0); + /// ``` + #[inline] + pub fn area(&self) -> f32 { + self.width() * self.height() + } + + /// Scale this rect by a multiplicative factor + /// + /// # Examples + /// + /// ``` + /// # use bevy_math::Rect; + /// let r = Rect::new(1., 1., 2., 2.); // w=10 h=10 + /// assert_eq!(r.scale(2.).min.x, 2.0); + /// assert_eq!(r.scale(2.).max.x, 4.0); + /// ``` + #[inline] + pub fn scale(&self, factor: f32) -> Rect { + Self { + min: self.min * factor, + max: self.max * factor, + } + } + /// Returns self as [`IRect`] (i32) #[inline] pub fn as_irect(&self) -> IRect { diff --git a/examples/ui/core_widgets.rs b/examples/ui/core_widgets.rs index 0398669e244ea..9d83612b58547 100644 --- a/examples/ui/core_widgets.rs +++ b/examples/ui/core_widgets.rs @@ -3,7 +3,8 @@ use bevy::{ color::palettes::basic::*, core_widgets::{ - Callback, CoreButton, CoreCheckbox, CoreRadio, CoreRadioGroup, CoreSlider, + popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide}, + Callback, CoreButton, CoreCheckbox, CoreMenuPopup, CoreRadio, CoreRadioGroup, CoreSlider, CoreSliderDragState, CoreSliderThumb, CoreWidgetsPlugin, SliderRange, SliderValue, TrackClick, }, @@ -26,7 +27,7 @@ fn main() { TabNavigationPlugin, )) // Only run the app when there is user input. This will significantly reduce CPU/GPU use. - .insert_resource(WinitSettings::desktop_app()) + // .insert_resource(WinitSettings::desktop_app()) .insert_resource(DemoWidgetStates { slider_value: 50.0, slider_click: TrackClick::Snap, @@ -156,6 +157,7 @@ fn setup(mut commands: Commands, assets: Res) { Callback::System(on_click), Callback::System(on_change_value), Callback::System(on_change_radio), + Callback::System(on_open_menu), )); } @@ -164,6 +166,7 @@ fn demo_root( on_click: Callback, on_change_value: Callback>, on_change_radio: Callback>, + on_open_menu: Callback, ) -> impl Bundle { ( Node { @@ -182,6 +185,7 @@ fn demo_root( slider(0.0, 100.0, 50.0, on_change_value), checkbox(asset_server, "Checkbox", Callback::Ignore), radio_group(asset_server, on_change_radio), + menu_button(asset_server, on_open_menu), Text::new("Press 'D' to toggle widget disabled states"), ], ) @@ -219,21 +223,20 @@ fn button(asset_server: &AssetServer, on_click: Callback) -> impl Bundle { ) } -fn menu_button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle { +fn menu_button(asset_server: &AssetServer, on_activate: Callback) -> impl Bundle { ( Node { width: Val::Px(200.0), height: Val::Px(65.0), border: UiRect::all(Val::Px(5.0)), + box_sizing: BoxSizing::BorderBox, justify_content: JustifyContent::SpaceBetween, align_items: AlignItems::Center, padding: UiRect::axes(Val::Px(16.0), Val::Px(0.0)), ..default() }, DemoMenuButton, - CoreButton { - on_click: Callback::System(on_click), - }, + CoreButton { on_activate }, Hovered::default(), TabIndex(0), BorderColor::all(Color::BLACK), @@ -792,36 +795,38 @@ fn spawn_popup(menu: Query>, mut commands: Commands let Ok(anchor) = menu.single() else { return; }; - commands.entity(anchor).insert(PortalChildren::spawn_one(( - Node { - min_height: Val::Px(100.), - min_width: Val::Px(100.), - border: UiRect::all(Val::Px(2.0)), - position_type: PositionType::Absolute, - left: Val::Px(100.), - ..default() - }, - BorderColor::all(GREEN.into()), - BackgroundColor(GRAY.into()), - ZIndex(100), - Floating { - anchor: FloatAnchor::Node(anchor), - positions: vec![ - FloatPosition { - side: FloatSide::Bottom, - align: FloatAlign::Start, - gap: 2.0, - ..default() - }, - FloatPosition { - side: FloatSide::Top, - align: FloatAlign::Start, - gap: 2.0, - ..default() - }, - ], - }, - ))); + let menu = commands + .spawn(( + Node { + min_height: Val::Px(100.), + min_width: Val::Percent(100.), + border: UiRect::all(Val::Px(2.0)), + position_type: PositionType::Absolute, + ..default() + }, + CoreMenuPopup, + Visibility::Hidden, // Will be visible after positioning + BorderColor::all(GREEN.into()), + BackgroundColor(GRAY.into()), + ZIndex(100), + Popover { + positions: vec![ + PopoverPlacement { + side: PopoverSide::Bottom, + align: PopoverAlign::Start, + gap: 2.0, + }, + PopoverPlacement { + side: PopoverSide::Top, + align: PopoverAlign::Start, + gap: 2.0, + }, + ], + }, + OverrideClip, + )) + .id(); + commands.entity(anchor).add_child(menu); info!("Open menu"); }