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 new file mode 100644 index 0000000000000..1f3866b17c20b --- /dev/null +++ b/crates/bevy_core_widgets/src/core_menu.rs @@ -0,0 +1,235 @@ +//! Core widget components for menus and menu buttons. + +use accesskit::Role; +use bevy_a11y::AccessibilityNode; +use bevy_app::{App, Plugin}; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::{EntityEvent, Event}, + 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::{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. +/// +/// 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(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, + /// 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). + 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). + 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, +} + +/// 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)), + TabGroup::modal() +)] +pub struct CoreMenuPopup; + +/// Component that defines a menu item. +#[derive(Component, Debug)] +#[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))] +pub struct CoreMenuItem { + /// 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_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/lib.rs b/crates/bevy_core_widgets/src/lib.rs index 2a3fc1ac097cd..6e41437f548f2 100644 --- a/crates/bevy_core_widgets/src/lib.rs +++ b/crates/bevy_core_widgets/src/lib.rs @@ -17,15 +17,18 @@ mod callback; mod core_button; mod core_checkbox; +mod core_menu; mod core_radio; mod core_scrollbar; mod core_slider; +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, @@ -36,6 +39,8 @@ pub use core_slider::{ SliderRange, SliderStep, SliderValue, TrackClick, }; +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. pub struct CoreWidgetsPlugin; @@ -43,8 +48,10 @@ pub struct CoreWidgetsPlugin; impl Plugin for CoreWidgetsPlugin { fn build(&self, app: &mut App) { app.add_plugins(( + 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_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 86aaa820f8e45..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, @@ -78,6 +79,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 +137,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, @@ -150,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), )); } @@ -158,6 +166,7 @@ fn demo_root( on_click: Callback, on_change_value: Callback>, on_change_radio: Callback>, + on_open_menu: Callback, ) -> impl Bundle { ( Node { @@ -176,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"), ], ) @@ -213,6 +223,48 @@ fn button(asset_server: &AssetServer, on_click: Callback) -> 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_activate }, + 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 +791,45 @@ 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; + }; + 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"); +} + fn toggle_disabled( input: Res>, mut interaction_query: Query<