diff --git a/crates/bevy_input_focus/src/tab_navigation.rs b/crates/bevy_input_focus/src/tab_navigation.rs index b8ce6a8d95375..fbaf5f100a7c8 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..c257ef9a8865f 100644 --- a/crates/bevy_math/src/rects/rect.rs +++ b/crates/bevy_math/src/rects/rect.rs @@ -356,6 +356,20 @@ 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() + } + /// Returns self as [`IRect`] (i32) #[inline] pub fn as_irect(&self) -> IRect { diff --git a/crates/bevy_ui_widgets/Cargo.toml b/crates/bevy_ui_widgets/Cargo.toml index af73ebb8d16d3..2b6849bb15216 100644 --- a/crates/bevy_ui_widgets/Cargo.toml +++ b/crates/bevy_ui_widgets/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["bevy"] # bevy bevy_app = { path = "../bevy_app", version = "0.18.0-dev" } bevy_a11y = { path = "../bevy_a11y", version = "0.18.0-dev" } +bevy_camera = { path = "../bevy_camera", version = "0.18.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.18.0-dev" } bevy_input = { path = "../bevy_input", version = "0.18.0-dev" } bevy_input_focus = { path = "../bevy_input_focus", version = "0.18.0-dev" } diff --git a/crates/bevy_ui_widgets/src/lib.rs b/crates/bevy_ui_widgets/src/lib.rs index 3ab239412775e..8f30e2842701a 100644 --- a/crates/bevy_ui_widgets/src/lib.rs +++ b/crates/bevy_ui_widgets/src/lib.rs @@ -20,13 +20,16 @@ mod button; mod checkbox; +mod menu; mod observe; +pub mod popover; mod radio; mod scrollbar; mod slider; pub use button::*; pub use checkbox::*; +pub use menu::*; pub use observe::*; pub use radio::*; pub use scrollbar::*; @@ -35,6 +38,8 @@ pub use slider::*; use bevy_app::{PluginGroup, PluginGroupBuilder}; use bevy_ecs::{entity::Entity, event::EntityEvent}; +use crate::popover::PopoverPlugin; + /// A plugin group that registers the observers for all of the widgets in this crate. If you don't want to /// use all of the widgets, you can import the individual widget plugins instead. pub struct UiWidgetsPlugins; @@ -42,8 +47,10 @@ pub struct UiWidgetsPlugins; impl PluginGroup for UiWidgetsPlugins { fn build(self) -> PluginGroupBuilder { PluginGroupBuilder::start::() + .add(PopoverPlugin) .add(ButtonPlugin) .add(CheckboxPlugin) + .add(MenuPlugin) .add(RadioGroupPlugin) .add(ScrollbarPlugin) .add(SliderPlugin) diff --git a/crates/bevy_ui_widgets/src/menu.rs b/crates/bevy_ui_widgets/src/menu.rs new file mode 100644 index 0000000000000..2501950b37596 --- /dev/null +++ b/crates/bevy_ui_widgets/src/menu.rs @@ -0,0 +1,429 @@ +//! Standard widget components for popup menus. + +use accesskit::Role; +use bevy_a11y::AccessibilityNode; +use bevy_app::{App, Plugin, Update}; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::EntityEvent, + hierarchy::ChildOf, + observer::On, + query::{Has, With, Without}, + schedule::IntoScheduleConfigs, + system::{Commands, Query, Res, ResMut}, +}; +use bevy_input::{ + keyboard::{KeyCode, KeyboardInput}, + ButtonState, +}; +use bevy_input_focus::{ + tab_navigation::{NavAction, TabGroup, TabNavigation}, + FocusedInput, InputFocus, +}; +use bevy_log::warn; +use bevy_picking::events::{Cancel, Click, DragEnd, Pointer, Press, Release}; +use bevy_ui::{InteractionDisabled, Pressed}; + +use crate::Activate; + +/// Action type for [`MenuEvent`]. +#[derive(Clone, Copy, Debug)] +pub enum MenuAction { + /// Indicates we want to open the menu, if it is not already open. + Open, + /// Open the menu if it's closed, close it if it's open. Generally sent from a menu button. + Toggle, + /// Close the menu and despawn it. Despawning may not happen immediately if there is a closing + /// transition animation. + Close, + /// Close the entire menu stack. + CloseAll, + /// Set focus to the menu button or other owner of the popup stack. This happens when + /// the escape key is pressed. + FocusRoot, +} + +/// Event used 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. 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(EntityEvent, Clone, Debug)] +#[entity_event(propagate, auto_propagate)] +pub struct MenuEvent { + /// The [`MenuItem`] or [`MenuPopup`] that triggered this event. + #[event_target] + pub source: Entity, + + /// The desired action in response to this event. + pub action: MenuAction, +} + +/// Specifies the layout direction of the menu, for keyboard navigation +#[derive(Default, Debug, Clone, PartialEq)] +pub enum MenuLayout { + /// A vertical stack. Up and down arrows to move between items. + #[default] + Column, + /// A horizontal row. Left and right arrows to move between items. + Row, + /// A 2D grid. Arrow keys are not mapped, you'll need to write your own observer. + Grid, +} + +/// Component that defines a popup menu container. +/// +/// Menus are automatically dismissed when the user clicks outside the menu bounds. Unlike a modal +/// dialog, where the click event is intercepted, we don't want to actually prevent the click event +/// from triggering its normal action. The easiest way to detect this kind of click is to look for +/// keyboard focus loss. When a menu is opened, one of its children will gain focus, and the menu +/// remains open so long as at least one descendant is focused. Arrow keys can be used to navigate +/// between menu items. +/// +/// This means that popup menu *must* contain at least one focusable entity. It also means that two +/// menus cannot be displayed at the same time unless one is an ancestor of the other. +/// +/// Some care needs to be taken in implementing a menu button: we normally want menu buttons to +/// toggle the open state of the menu; but clicking on the button will cause focus loss which means +/// that the menu will always be closed by the time the click event is processed. +#[derive(Component, Debug, Default, Clone)] +#[require( + AccessibilityNode(accesskit::Node::new(Role::MenuListPopup)), + TabGroup::modal() +)] +#[require(MenuAcquireFocus)] +pub struct MenuPopup { + /// The layout orientation of the menu + pub layout: MenuLayout, +} + +/// Component that defines a menu item. +#[derive(Component, Debug, Clone)] +#[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))] +pub struct MenuItem; + +/// Marker component that indicates that we need to set focus to the first menu item. +#[derive(Component, Debug, Default)] +struct MenuAcquireFocus; + +/// Component that indicates that the menu lost focus and is in the process of closing. +#[derive(Component, Debug, Default)] +struct MenuLostFocus; + +fn menu_acquire_focus( + q_menus: Query, With)>, + mut focus: ResMut, + tab_navigation: TabNavigation, + mut commands: Commands, +) { + for menu in q_menus.iter() { + // When a menu is spawned, attempt to find the first focusable menu item, and set focus + // to it. + match tab_navigation.initialize(menu, NavAction::First) { + Ok(next) => { + commands.entity(menu).remove::(); + focus.0 = Some(next); + } + Err(e) => { + warn!( + "No focusable menu items for popup menu: {}, error: {:?}", + menu, e + ); + } + } + } +} + +fn menu_on_lose_focus( + q_menus: Query< + Entity, + ( + With, + Without, + Without, + ), + >, + q_parent: Query<&ChildOf>, + focus: Res, + mut commands: Commands, +) { + // Close any menu which doesn't contain the focus entity. + for menu in q_menus.iter() { + // TODO: Change this logic when we support submenus. Don't want to send multiple close + // events. Perhaps what we can do is add `MenuLostFocus` to the whole stack. + let contains_focus = match focus.0 { + Some(focus_ent) => { + focus_ent == menu || q_parent.iter_ancestors(focus_ent).any(|ent| ent == menu) + } + None => false, + }; + + if !contains_focus { + commands.entity(menu).insert(MenuLostFocus); + commands.trigger(MenuEvent { + source: menu, + action: MenuAction::CloseAll, + }); + } + } +} + +fn menu_on_key_event( + mut ev: On>, + q_item: Query, With>, + q_menu: Query<&MenuPopup>, + tab_navigation: TabNavigation, + mut focus: ResMut, + mut commands: Commands, +) { + if let Ok(disabled) = q_item.get(ev.focused_entity) { + if !disabled { + let event = &ev.event().input; + let entity = ev.event().focused_entity; + 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); + // Trigger the action for this menu item. + commands.trigger(Activate { entity }); + // Set the focus to the menu button. + commands.trigger(MenuEvent { + source: entity, + action: MenuAction::FocusRoot, + }); + // Close the stack + commands.trigger(MenuEvent { + source: entity, + action: MenuAction::CloseAll, + }); + } + + _ => (), + } + } + } + } else if let Ok(menu) = q_menu.get(ev.focused_entity) { + let event = &ev.event().input; + if !event.repeat && event.state == ButtonState::Pressed { + match event.key_code { + // Close the popup + KeyCode::Escape => { + ev.propagate(false); + // Set the focus to the menu button. + commands.trigger(MenuEvent { + source: ev.focused_entity, + action: MenuAction::FocusRoot, + }); + // Close the stack + commands.trigger(MenuEvent { + source: ev.focused_entity, + action: MenuAction::CloseAll, + }); + } + + // Focus the adjacent item in the up direction + KeyCode::ArrowUp => { + if menu.layout == MenuLayout::Column { + ev.propagate(false); + focus.0 = tab_navigation.navigate(&focus, NavAction::Previous).ok(); + } + } + + // Focus the adjacent item in the down direction + KeyCode::ArrowDown => { + if menu.layout == MenuLayout::Column { + ev.propagate(false); + focus.0 = tab_navigation.navigate(&focus, NavAction::Next).ok(); + } + } + + // Focus the adjacent item in the left direction + KeyCode::ArrowLeft => { + if menu.layout == MenuLayout::Row { + ev.propagate(false); + focus.0 = tab_navigation.navigate(&focus, NavAction::Previous).ok(); + } + } + + // Focus the adjacent item in the right direction + KeyCode::ArrowRight => { + if menu.layout == MenuLayout::Row { + ev.propagate(false); + focus.0 = tab_navigation.navigate(&focus, NavAction::Next).ok(); + } + } + + // Focus the first item + KeyCode::Home => { + ev.propagate(false); + focus.0 = tab_navigation.navigate(&focus, NavAction::First).ok(); + } + + // Focus the last item + KeyCode::End => { + ev.propagate(false); + focus.0 = tab_navigation.navigate(&focus, NavAction::Last).ok(); + } + + _ => (), + } + } + } +} + +fn menu_item_on_pointer_click( + mut ev: On>, + mut q_state: Query<(Has, Has), With>, + mut commands: Commands, +) { + if let Ok((pressed, disabled)) = q_state.get_mut(ev.entity) { + ev.propagate(false); + if pressed && !disabled { + // Trigger the menu action. + commands.trigger(Activate { entity: ev.entity }); + // Set the focus to the menu button. + commands.trigger(MenuEvent { + source: ev.entity, + action: MenuAction::FocusRoot, + }); + // Close the stack + commands.trigger(MenuEvent { + source: ev.entity, + action: MenuAction::CloseAll, + }); + } + } +} + +fn menu_item_on_pointer_down( + mut ev: On>, + mut q_state: Query<(Entity, Has, Has), With>, + mut commands: Commands, +) { + if let Ok((item, disabled, pressed)) = q_state.get_mut(ev.entity) { + ev.propagate(false); + if !disabled && !pressed { + commands.entity(item).insert(Pressed); + } + } +} + +fn menu_item_on_pointer_up( + mut ev: On>, + mut q_state: Query<(Entity, Has, Has), With>, + mut commands: Commands, +) { + if let Ok((item, disabled, pressed)) = q_state.get_mut(ev.entity) { + ev.propagate(false); + if !disabled && pressed { + commands.entity(item).remove::(); + } + } +} + +fn menu_item_on_pointer_drag_end( + mut ev: On>, + mut q_state: Query<(Entity, Has, Has), With>, + mut commands: Commands, +) { + if let Ok((item, disabled, pressed)) = q_state.get_mut(ev.entity) { + ev.propagate(false); + if !disabled && pressed { + commands.entity(item).remove::(); + } + } +} + +fn menu_item_on_pointer_cancel( + mut ev: On>, + mut q_state: Query<(Entity, Has, Has), With>, + mut commands: Commands, +) { + if let Ok((item, disabled, pressed)) = q_state.get_mut(ev.entity) { + ev.propagate(false); + if !disabled && pressed { + commands.entity(item).remove::(); + } + } +} + +fn menu_on_menu_event( + mut ev: On, + q_popup: Query<(), With>, + mut commands: Commands, +) { + if q_popup.contains(ev.source) + && let MenuAction::Close = ev.event().action + { + ev.propagate(false); + commands.entity(ev.source).despawn(); + } +} + +/// Headless menu button widget. This is similar to a button, except for a few differences: +/// * It emits a menu toggle event when pressed or activated. +/// * It uses `Pointer` rather than click, so as to process the pointer event before +/// stealing focus from the menu. +#[derive(Component, Default, Debug)] +#[require(AccessibilityNode(accesskit::Node::new(Role::Button)))] +pub struct MenuButton; + +fn menubutton_on_key_event( + mut event: On>, + q_state: Query, With>, + mut commands: Commands, +) { + if let Ok(disabled) = q_state.get(event.focused_entity) + && !disabled + { + let input_event = &event.input; + if !input_event.repeat + && input_event.state == ButtonState::Pressed + && (input_event.key_code == KeyCode::Enter || input_event.key_code == KeyCode::Space) + { + event.propagate(false); + commands.trigger(MenuEvent { + action: MenuAction::Toggle, + source: event.focused_entity, + }); + } + } +} + +fn menubutton_on_pointer_press( + mut press: On>, + mut q_state: Query<(Entity, Has, Has), With>, + mut commands: Commands, +) { + if let Ok((button, disabled, pressed)) = q_state.get_mut(press.entity) { + press.propagate(false); + if !disabled && !pressed { + commands.trigger(MenuEvent { + action: MenuAction::Toggle, + source: button, + }); + } + } +} + +/// Plugin that adds the observers for the [`MenuItem`] component. +pub struct MenuPlugin; + +impl Plugin for MenuPlugin { + fn build(&self, app: &mut App) { + app.add_systems(Update, (menu_acquire_focus, menu_on_lose_focus).chain()) + .add_observer(menu_on_key_event) + .add_observer(menu_on_menu_event) + .add_observer(menu_item_on_pointer_down) + .add_observer(menu_item_on_pointer_up) + .add_observer(menu_item_on_pointer_click) + .add_observer(menu_item_on_pointer_drag_end) + .add_observer(menu_item_on_pointer_cancel) + .add_observer(menubutton_on_key_event) + .add_observer(menubutton_on_pointer_press); + } +} diff --git a/crates/bevy_ui_widgets/src/popover.rs b/crates/bevy_ui_widgets/src/popover.rs new file mode 100644 index 0000000000000..59a811aa1ffb0 --- /dev/null +++ b/crates/bevy_ui_widgets/src/popover.rs @@ -0,0 +1,258 @@ +//! Framework for positioning of popups, tooltips, and other popover UI elements. + +use bevy_app::{App, Plugin, PostUpdate}; +use bevy_camera::visibility::Visibility; +use bevy_ecs::{ + change_detection::DetectChangesMut, component::Component, hierarchy::ChildOf, query::Without, + schedule::IntoScheduleConfigs, system::Query, +}; +use bevy_math::{Rect, Vec2}; +use bevy_ui::{ + ComputedNode, ComputedUiRenderTargetInfo, 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 alignment +/// 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 center of the popover element should be aligned to the center of the parent. + Center, + /// The ending edge of the popover element should be aligned to the ending edge of the parent. + End, +} + +/// 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 being clipped by the window edge. 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, Default)] +pub struct Popover { + /// List of potential positions for the popover element relative to the parent. + pub positions: Vec, + + /// Indicates how close to the window edge the popup is allowed to go. + pub window_margin: f32, +} + +impl Clone for Popover { + fn clone(&self) -> Self { + Self { + positions: self.positions.clone(), + window_margin: self.window_margin, + } + } +} + +fn position_popover( + mut q_popover: Query<( + &mut Node, + &mut Visibility, + &ComputedNode, + &ComputedUiRenderTargetInfo, + &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(), + } + .inflate(-popover.window_margin); + + // 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 = scale_rect( + Rect::from_center_size(parent_transform.translation, parent_size), + 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.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.min.y + (parent_rect.height() - 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(PostUpdate, position_popover.in_set(UiSystems::Prepare)); + } +} + +#[inline] +fn scale_rect(rect: Rect, factor: f32) -> Rect { + Rect { + min: rect.min * factor, + max: rect.max * factor, + } +} diff --git a/examples/ui/standard_widgets.rs b/examples/ui/standard_widgets.rs index a8a0db1d05db1..f3308b0d5e86a 100644 --- a/examples/ui/standard_widgets.rs +++ b/examples/ui/standard_widgets.rs @@ -10,15 +10,17 @@ use bevy::{ color::palettes::basic::*, input_focus::{ tab_navigation::{TabGroup, TabIndex, TabNavigationPlugin}, - InputDispatchPlugin, + InputDispatchPlugin, InputFocus, }, picking::hover::Hovered, prelude::*, ui::{Checked, InteractionDisabled, Pressed}, ui_widgets::{ - checkbox_self_update, observe, Activate, Button, Checkbox, CoreSliderDragState, - RadioButton, RadioGroup, Slider, SliderRange, SliderThumb, SliderValue, TrackClick, - UiWidgetsPlugins, ValueChange, + checkbox_self_update, observe, + popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide}, + Activate, Button, Checkbox, CoreSliderDragState, MenuAction, MenuButton, MenuEvent, + MenuItem, MenuPopup, RadioButton, RadioGroup, Slider, SliderRange, SliderThumb, + SliderValue, TrackClick, UiWidgetsPlugins, ValueChange, }, }; @@ -45,6 +47,8 @@ fn main() { update_slider_style2.after(update_widget_values), update_checkbox_or_radio_style.after(update_widget_values), update_checkbox_or_radio_style2.after(update_widget_values), + update_menu_item_style, + update_menu_item_style2, toggle_disabled, ), ) @@ -81,6 +85,18 @@ struct DemoCheckbox; #[derive(Component, Default)] struct DemoRadio(TrackClick); +/// Menu anchor marker +#[derive(Component)] +struct DemoMenuAnchor; + +/// Menu button styling marker +#[derive(Component)] +struct DemoMenuButton; + +/// Menu item styling marker +#[derive(Component)] +struct DemoMenuItem; + /// 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, @@ -172,6 +188,7 @@ fn demo_root(asset_server: &AssetServer) -> impl Bundle { }, ) ), + menu_button(asset_server), Text::new("Press 'D' to toggle widget disabled states"), ], ) @@ -207,6 +224,53 @@ fn button(asset_server: &AssetServer) -> impl Bundle { ) } +fn menu_button(asset_server: &AssetServer) -> impl Bundle { + ( + Node { ..default() }, + DemoMenuAnchor, + observe(on_menu_event), + children![( + Node { + width: px(200), + height: px(65), + border: UiRect::all(px(5)), + box_sizing: BoxSizing::BorderBox, + justify_content: JustifyContent::SpaceBetween, + align_items: AlignItems::Center, + padding: UiRect::axes(px(16), px(0)), + ..default() + }, + DemoMenuButton, + MenuButton, + Hovered::default(), + TabIndex(0), + BorderColor::all(Color::BLACK), + BorderRadius::all(px(5)), + 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: px(12), + height: px(12), + ..default() + }, + BackgroundColor(GRAY.into()), + ) + ], + )], + ) +} + fn update_button_style( mut buttons: Query< ( @@ -728,12 +792,188 @@ fn radio(asset_server: &AssetServer, value: TrackClick, caption: &str) -> impl B ) } +fn on_menu_event( + menu_event: On, + q_anchor: Single<(Entity, &Children), With>, + q_popup: Query>, + assets: Res, + mut focus: ResMut, + mut commands: Commands, +) { + let (anchor, children) = q_anchor.into_inner(); + let popup = children.iter().find_map(|c| q_popup.get(c).ok()); + info!("Menu action: {:?}", menu_event.action); + match menu_event.action { + MenuAction::Open => { + if popup.is_none() { + spawn_menu(anchor, assets, commands); + } + } + MenuAction::Toggle => match popup { + Some(popup) => commands.entity(popup).despawn(), + None => spawn_menu(anchor, assets, commands), + }, + MenuAction::Close | MenuAction::CloseAll => { + if let Some(popup) = popup { + commands.entity(popup).despawn(); + } + } + MenuAction::FocusRoot => { + focus.0 = Some(anchor); + } + } +} + +fn spawn_menu(anchor: Entity, assets: Res, mut commands: Commands) { + let menu = commands + .spawn(( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + min_height: px(10.), + min_width: Val::Percent(100.), + border: UiRect::all(px(1)), + position_type: PositionType::Absolute, + ..default() + }, + MenuPopup::default(), + Visibility::Hidden, // Will be visible after positioning + BorderColor::all(GREEN), + BackgroundColor(GRAY.into()), + BoxShadow::new( + Srgba::BLACK.with_alpha(0.9).into(), + px(0), + px(0), + px(1), + px(4), + ), + GlobalZIndex(100), + 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, + children![ + menu_item(&assets), + menu_item(&assets), + menu_item(&assets), + menu_item(&assets) + ], + )) + .id(); + commands.entity(anchor).add_child(menu); +} + +fn menu_item(asset_server: &AssetServer) -> impl Bundle { + ( + Node { + padding: UiRect::axes(px(8), px(2)), + justify_content: JustifyContent::Center, + align_items: AlignItems::Start, + ..default() + }, + DemoMenuItem, + MenuItem, + Hovered::default(), + TabIndex(0), + BackgroundColor(NORMAL_BUTTON), + children![( + Text::new("Menu Item"), + 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(), + )], + ) +} + +fn update_menu_item_style( + mut buttons: Query< + ( + Has, + &Hovered, + Has, + &mut BackgroundColor, + ), + ( + Or<( + Changed, + Changed, + Added, + )>, + With, + ), + >, +) { + for (pressed, hovered, disabled, mut color) in &mut buttons { + set_menu_item_style(disabled, hovered.get(), pressed, &mut color); + } +} + +/// Supplementary system to detect removed marker components +fn update_menu_item_style2( + mut buttons: Query< + ( + Has, + &Hovered, + Has, + &mut BackgroundColor, + ), + With, + >, + mut removed_depressed: RemovedComponents, + mut removed_disabled: RemovedComponents, +) { + removed_depressed + .read() + .chain(removed_disabled.read()) + .for_each(|entity| { + if let Ok((pressed, hovered, disabled, mut color)) = buttons.get_mut(entity) { + set_menu_item_style(disabled, hovered.get(), pressed, &mut color); + } + }); +} + +fn set_menu_item_style(disabled: bool, hovered: bool, pressed: bool, color: &mut BackgroundColor) { + match (disabled, hovered, pressed) { + // Pressed and hovered menu item + (false, true, true) => { + *color = PRESSED_BUTTON.into(); + } + + // Hovered, unpressed menu item + (false, true, false) => { + *color = HOVERED_BUTTON.into(); + } + + // Unhovered menu item (either pressed or not). + _ => { + *color = NORMAL_BUTTON.into(); + } + } +} + fn toggle_disabled( input: Res>, mut interaction_query: Query< (Entity, Has), Or<( With