diff --git a/crates/bevy_feathers/src/assets/icons/_NAMING.md b/crates/bevy_feathers/src/assets/icons/_NAMING.md new file mode 100644 index 0000000000000..baffdedb4103b --- /dev/null +++ b/crates/bevy_feathers/src/assets/icons/_NAMING.md @@ -0,0 +1,2 @@ +The names of files in this directory should refer to what the icons look like ("x", "chevron", etc.) +rather than their assigned meanings ("close", "expand") because the latter can change. diff --git a/crates/bevy_feathers/src/assets/icons/chevron-down.png b/crates/bevy_feathers/src/assets/icons/chevron-down.png new file mode 100644 index 0000000000000..ba8a4c42104e0 Binary files /dev/null and b/crates/bevy_feathers/src/assets/icons/chevron-down.png differ diff --git a/crates/bevy_feathers/src/assets/icons/chevron-right.png b/crates/bevy_feathers/src/assets/icons/chevron-right.png new file mode 100644 index 0000000000000..681c167ac0152 Binary files /dev/null and b/crates/bevy_feathers/src/assets/icons/chevron-right.png differ diff --git a/crates/bevy_feathers/src/assets/icons/x.png b/crates/bevy_feathers/src/assets/icons/x.png new file mode 100644 index 0000000000000..23cff4ac589c8 Binary files /dev/null and b/crates/bevy_feathers/src/assets/icons/x.png differ diff --git a/crates/bevy_feathers/src/constants.rs b/crates/bevy_feathers/src/constants.rs index 359e5a4935b0c..14789f0083c39 100644 --- a/crates/bevy_feathers/src/constants.rs +++ b/crates/bevy_feathers/src/constants.rs @@ -14,6 +14,16 @@ pub mod fonts { pub const MONO: &str = "embedded://bevy_feathers/assets/fonts/FiraMono-Medium.ttf"; } +/// Icon paths +pub mod icons { + /// Downward-pointing chevron + pub const CHEVRON_DOWN: &str = "embedded://bevy_feathers/assets/icons/chevron-down.png"; + /// Right-pointing chevron + pub const CHEVRON_RIGHT: &str = "embedded://bevy_feathers/assets/icons/chevron-right.png"; + /// Diagonal Cross + pub const X: &str = "embedded://bevy_feathers/assets/icons/x.png"; +} + /// Size constants pub mod size { use bevy_ui::Val; diff --git a/crates/bevy_feathers/src/controls/menu.rs b/crates/bevy_feathers/src/controls/menu.rs new file mode 100644 index 0000000000000..cc30c2623835a --- /dev/null +++ b/crates/bevy_feathers/src/controls/menu.rs @@ -0,0 +1,329 @@ +use alloc::sync::Arc; + +use bevy_app::{Plugin, PreUpdate}; +use bevy_camera::visibility::Visibility; +use bevy_color::{Alpha, Srgba}; +use bevy_ecs::{ + component::Component, + entity::Entity, + hierarchy::Children, + lifecycle::RemovedComponents, + observer::On, + query::{Added, Changed, Has, Or, With}, + schedule::IntoScheduleConfigs, + system::{Commands, EntityCommands, Query}, +}; +use bevy_log::info; +use bevy_picking::{ + events::{Click, Pointer}, + hover::Hovered, + PickingSystems, +}; +use bevy_scene2::{prelude::*, template_value}; +use bevy_ui::{ + AlignItems, BoxShadow, Display, FlexDirection, GlobalZIndex, InteractionDisabled, + JustifyContent, Node, OverrideClip, PositionType, Pressed, UiRect, Val, +}; +use bevy_ui_widgets::{ + popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide}, + MenuAction, MenuEvent, MenuItem, MenuPopup, +}; + +use crate::{ + constants::{fonts, icons, size}, + controls::{button, ButtonProps, ButtonVariant}, + font_styles::InheritableFont, + icon, + rounded_corners::RoundedCorners, + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, + tokens, +}; +use bevy_input_focus::tab_navigation::TabIndex; + +/// Parameters for the menu button template, passed to [`menu_button`] function. +#[derive(Default)] +pub struct MenuButtonProps { + /// Rounded corners options + pub corners: RoundedCorners, +} + +/// Marker for menu items +#[derive(Component, Default, Clone)] +struct MenuItemStyle; + +/// Marker for menu popup +#[derive(Component, Default, Clone)] +struct MenuPopupStyle; + +/// Marker for menu wrapper +#[derive(Component, Clone, Default)] +struct Menu(Option>); + +/// Menu scene function. This wraps the menu button and provides an anchor for the popopver. +pub fn menu(spawn_popover: F) -> impl Scene { + let menu = Menu(Some(Arc::new(spawn_popover))); + bsn! { + Node { + height: size::ROW_HEIGHT, + justify_content: JustifyContent::Stretch, + align_items: AlignItems::Stretch, + } + template_value(menu) + on(| + ev: On, + q_menu: Query<(&Menu, &Children)>, + q_popovers: Query>, + // mut redraw_events: MessageWriter, + mut commands: Commands| { + match ev.event().action { + // MenuEvent::Open => todo!(), + // MenuEvent::Close => todo!(), + MenuAction::Toggle => { + let mut was_open = false; + let Ok((menu, children)) = q_menu.get(ev.source) else { + return; + }; + for child in children.iter() { + if q_popovers.contains(*child) { + commands.entity(*child).despawn(); + was_open = true; + } + } + // Spawn the menu if not already open. + if !was_open { + info!("Opening, !was_open"); + if let Some(factory) = menu.0.as_ref() { + (*factory)(commands.entity(ev.source)); + // redraw_events.write(RequestRedraw); + } + } + }, + MenuAction::CloseAll => { + let Ok((_menu, children)) = q_menu.get(ev.source) else { + return; + }; + for child in children.iter() { + if q_popovers.contains(*child) { + commands.entity(*child).despawn(); + } + } + }, + // MenuEvent::FocusRoot => todo!(), + event => { + info!("Menu Event: {:?}", event); + } + } + }) + } +} + +/// Button scene function. +/// +/// # Arguments +/// * `props` - construction properties for the button. +pub fn menu_button(props: MenuButtonProps) -> impl Scene { + bsn! { + :button(ButtonProps { + variant: ButtonVariant::Normal, + corners: props.corners, + }) + Node { + // TODO: HACK to deal with lack of intercepted children + flex_direction: FlexDirection::RowReverse, + } + on(|ev: On>, mut commands: Commands| { + commands.trigger(MenuEvent { source: ev.entity, action: MenuAction::Toggle }); + }) + [ + :icon(icons::CHEVRON_DOWN), + Node { + flex_grow: 0.2, + } + ] + } +} + +/// Menu Popup scene function +pub fn menu_popup() -> impl Scene { + bsn! { + Node { + position_type: PositionType::Absolute, + display: Display::Flex, + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Stretch, + align_items: AlignItems::Stretch, + border: UiRect::all(Val::Px(1.0)), + padding: UiRect::all(Val::Px(4.0)), + } + MenuPopupStyle + MenuPopup + template_value(Visibility::Hidden) + template_value(RoundedCorners::All.to_border_radius(4.0)) + ThemeBackgroundColor(tokens::MENU_BG) + ThemeBorderColor(tokens::MENU_BORDER) + BoxShadow::new( + Srgba::BLACK.with_alpha(0.9).into(), + Val::Px(0.0), + Val::Px(0.0), + Val::Px(1.0), + Val::Px(4.0), + ) + GlobalZIndex(100) + template_value( + Popover { + positions: vec![ + PopoverPlacement { + side: PopoverSide::Bottom, + align: PopoverAlign::Start, + gap: 2.0, + }, + PopoverPlacement { + side: PopoverSide::Top, + align: PopoverAlign::Start, + gap: 2.0, + }, + ], + window_margin: 10.0, + } + ) + OverrideClip + } +} + +/// Menu item scene function +pub fn menu_item() -> impl Scene { + bsn! { + Node { + height: size::ROW_HEIGHT, + min_width: size::ROW_HEIGHT, + justify_content: JustifyContent::Start, + align_items: AlignItems::Center, + padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)), + } + MenuItemStyle + MenuItem + Hovered + // TODO: port CursonIcon to GetTemplate + // CursorIcon::System(bevy_window::SystemCursorIcon::Pointer) + TabIndex(0) + ThemeBackgroundColor(tokens::MENU_BG) // Same as menu + ThemeFontColor(tokens::MENUITEM_TEXT) + InheritableFont { + font: fonts::REGULAR, + font_size: 14.0, + } + } +} + +fn update_menuitem_styles( + q_menuitems: Query< + ( + Entity, + Has, + Has, + &Hovered, + &ThemeBackgroundColor, + &ThemeFontColor, + ), + ( + With, + Or<(Changed, Added, Added)>, + ), + >, + mut commands: Commands, +) { + for (button_ent, disabled, pressed, hovered, bg_color, font_color) in q_menuitems.iter() { + set_menuitem_colors( + button_ent, + disabled, + pressed, + hovered.0, + bg_color, + font_color, + &mut commands, + ); + } +} + +fn update_menuitem_styles_remove( + q_menuitems: Query< + ( + Entity, + Has, + Has, + &Hovered, + &ThemeBackgroundColor, + &ThemeFontColor, + ), + With, + >, + mut removed_disabled: RemovedComponents, + mut removed_pressed: RemovedComponents, + mut commands: Commands, +) { + removed_disabled + .read() + .chain(removed_pressed.read()) + .for_each(|ent| { + if let Ok((button_ent, disabled, pressed, hovered, bg_color, font_color)) = + q_menuitems.get(ent) + { + set_menuitem_colors( + button_ent, + disabled, + pressed, + hovered.0, + bg_color, + font_color, + &mut commands, + ); + } + }); +} + +fn set_menuitem_colors( + button_ent: Entity, + disabled: bool, + pressed: bool, + hovered: bool, + bg_color: &ThemeBackgroundColor, + font_color: &ThemeFontColor, + commands: &mut Commands, +) { + let bg_token = match (pressed, hovered) { + (true, _) => tokens::MENUITEM_BG_PRESSED, + (false, true) => tokens::MENUITEM_BG_HOVER, + (false, false) => tokens::MENU_BG, + }; + + let font_color_token = match disabled { + true => tokens::MENUITEM_TEXT_DISABLED, + false => tokens::MENUITEM_TEXT, + }; + + // Change background color + if bg_color.0 != bg_token { + commands + .entity(button_ent) + .insert(ThemeBackgroundColor(bg_token)); + } + + // Change font color + if font_color.0 != font_color_token { + commands + .entity(button_ent) + .insert(ThemeFontColor(font_color_token)); + } +} + +/// Plugin which registers the systems for updating the menu and menu button styles. +pub struct MenuPlugin; + +impl Plugin for MenuPlugin { + fn build(&self, app: &mut bevy_app::App) { + app.add_systems( + PreUpdate, + (update_menuitem_styles, update_menuitem_styles_remove).in_set(PickingSystems::Last), + ); + } +} diff --git a/crates/bevy_feathers/src/controls/mod.rs b/crates/bevy_feathers/src/controls/mod.rs index 4475911d17adb..aacfb30923eef 100644 --- a/crates/bevy_feathers/src/controls/mod.rs +++ b/crates/bevy_feathers/src/controls/mod.rs @@ -5,6 +5,7 @@ mod button; mod checkbox; mod color_slider; mod color_swatch; +mod menu; mod radio; mod slider; mod toggle_switch; @@ -14,6 +15,7 @@ pub use button::*; pub use checkbox::*; pub use color_slider::*; pub use color_swatch::*; +pub use menu::*; pub use radio::*; pub use slider::*; pub use toggle_switch::*; @@ -31,6 +33,7 @@ impl Plugin for ControlsPlugin { ButtonPlugin, CheckboxPlugin, ColorSliderPlugin, + MenuPlugin, RadioPlugin, SliderPlugin, ToggleSwitchPlugin, diff --git a/crates/bevy_feathers/src/dark_theme.rs b/crates/bevy_feathers/src/dark_theme.rs index 4a7248852c51a..31a6ab3719ca6 100644 --- a/crates/bevy_feathers/src/dark_theme.rs +++ b/crates/bevy_feathers/src/dark_theme.rs @@ -95,6 +95,16 @@ pub fn create_dark_theme() -> ThemeProps { tokens::SWITCH_SLIDE_DISABLED, palette::LIGHT_GRAY_2.with_alpha(0.3), ), + // Menus + (tokens::MENU_BG, palette::GRAY_1), + (tokens::MENU_BORDER, palette::WARM_GRAY_1), + (tokens::MENUITEM_BG_HOVER, palette::GRAY_1.lighter(0.05)), + (tokens::MENUITEM_BG_PRESSED, palette::GRAY_1.lighter(0.1)), + (tokens::MENUITEM_TEXT, palette::WHITE), + ( + tokens::MENUITEM_TEXT_DISABLED, + palette::WHITE.with_alpha(0.5), + ), ]), } } diff --git a/crates/bevy_feathers/src/icon.rs b/crates/bevy_feathers/src/icon.rs new file mode 100644 index 0000000000000..29a93520045ae --- /dev/null +++ b/crates/bevy_feathers/src/icon.rs @@ -0,0 +1,18 @@ +//! BSN Template for loading images and displaying them as [`ImageNodes`]. +use bevy_asset::AssetServer; +use bevy_ecs::template::template; +use bevy_scene2::{bsn, Scene}; +use bevy_ui::{widget::ImageNode, Node, Val}; + +/// Template which displays an icon. +pub fn icon(image: &'static str) -> impl Scene { + bsn! { + Node { + height: Val::Px(14.0), + } + template(move |entity| { + let handle = entity.resource::().load(image); + Ok(ImageNode::new(handle)) + }) + } +} diff --git a/crates/bevy_feathers/src/lib.rs b/crates/bevy_feathers/src/lib.rs index 348b677f98e53..7752662f5fb18 100644 --- a/crates/bevy_feathers/src/lib.rs +++ b/crates/bevy_feathers/src/lib.rs @@ -18,6 +18,8 @@ //! Please report issues, submit fixes and propose changes. //! Thanks for stress-testing; let's build something better together. +extern crate alloc; + use bevy_app::{ HierarchyPropagatePlugin, Plugin, PluginGroup, PluginGroupBuilder, PostUpdate, PropagateSet, }; @@ -43,11 +45,14 @@ pub mod cursor; pub mod dark_theme; pub mod font_styles; pub mod handle_or_path; +mod icon; pub mod palette; pub mod rounded_corners; pub mod theme; pub mod tokens; +pub use icon::icon; + /// Plugin which installs observers and systems for feathers themes, cursors, and all controls. pub struct FeathersPlugin; @@ -62,6 +67,11 @@ impl Plugin for FeathersPlugin { embedded_asset!(app, "assets/fonts/FiraSans-Italic.ttf"); embedded_asset!(app, "assets/fonts/FiraMono-Medium.ttf"); + // Embedded icons + embedded_asset!(app, "assets/icons/chevron-down.png"); + embedded_asset!(app, "assets/icons/chevron-right.png"); + embedded_asset!(app, "assets/icons/x.png"); + // Embedded shader embedded_asset!(app, "assets/shaders/alpha_pattern.wgsl"); diff --git a/crates/bevy_feathers/src/tokens.rs b/crates/bevy_feathers/src/tokens.rs index a00a78bc799c0..9380b809799ee 100644 --- a/crates/bevy_feathers/src/tokens.rs +++ b/crates/bevy_feathers/src/tokens.rs @@ -137,3 +137,19 @@ pub const SWITCH_SLIDE: ThemeToken = ThemeToken::new_static("feathers.switch.sli /// Switch slide (disabled) pub const SWITCH_SLIDE_DISABLED: ThemeToken = ThemeToken::new_static("feathers.switch.slide.disabled"); + +// Menus + +/// Menu background +pub const MENU_BG: ThemeToken = ThemeToken::new_static("feathers.menu.bg"); +/// Menu border +pub const MENU_BORDER: ThemeToken = ThemeToken::new_static("feathers.menu.border"); +/// Menu item hovered +pub const MENUITEM_BG_HOVER: ThemeToken = ThemeToken::new_static("feathers.menuitem.bg.hover"); +/// Menu item pressed +pub const MENUITEM_BG_PRESSED: ThemeToken = ThemeToken::new_static("feathers.menuitem.bg.pressed"); +/// Menu item text +pub const MENUITEM_TEXT: ThemeToken = ThemeToken::new_static("feathers.menuitem.text"); +/// Menu item text (disabled) +pub const MENUITEM_TEXT_DISABLED: ThemeToken = + ThemeToken::new_static("feathers.menuitem.text.disabled"); diff --git a/crates/bevy_input_focus/src/tab_navigation.rs b/crates/bevy_input_focus/src/tab_navigation.rs index a490d00dda10e..e112c84d5ea26 100644 --- a/crates/bevy_input_focus/src/tab_navigation.rs +++ b/crates/bevy_input_focus/src/tab_navigation.rs @@ -167,12 +167,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. @@ -199,13 +199,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 { @@ -219,7 +252,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. @@ -269,7 +302,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/crates/bevy_ui/src/ui_node.rs b/crates/bevy_ui/src/ui_node.rs index cb29d5b07fafe..8100d72b16eb7 100644 --- a/crates/bevy_ui/src/ui_node.rs +++ b/crates/bevy_ui/src/ui_node.rs @@ -2244,7 +2244,7 @@ pub struct CalculatedClip { /// UI node entities with this component will ignore any clipping rect they inherit, /// the node will not be clipped regardless of its ancestors' `Overflow` setting. -#[derive(Component)] +#[derive(Component, Default, Clone)] pub struct OverrideClip; #[expect( diff --git a/crates/bevy_ui_widgets/Cargo.toml b/crates/bevy_ui_widgets/Cargo.toml index 38ae0753f6a6a..f2585fdfbbdd1 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.17.1" } bevy_a11y = { path = "../bevy_a11y", version = "0.17.1" } +bevy_camera = { path = "../bevy_camera", version = "0.17.1" } bevy_ecs = { path = "../bevy_ecs", version = "0.17.1" } bevy_input = { path = "../bevy_input", version = "0.17.1" } bevy_input_focus = { path = "../bevy_input_focus", version = "0.17.1" } 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..098ba81a3ff2c --- /dev/null +++ b/crates/bevy_ui_widgets/src/menu.rs @@ -0,0 +1,377 @@ +//! Core widget components for menus and menu buttons. + +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}, + template::GetTemplate, +}; +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. +/// +/// 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, 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, GetTemplate)] +#[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 is closing. +#[derive(Component, Debug, Default)] +struct MenuClosing; + +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 `CoreMenuClosing` 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(MenuClosing); + 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; + 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 menu action + commands.trigger(Activate { + entity: ev.event().focused_entity, + }); + // Set the focus to the menu button. + commands.trigger(MenuEvent { + source: ev.event().focused_entity, + action: MenuAction::FocusRoot, + }); + // Close the stack + commands.trigger(MenuEvent { + source: ev.event().focused_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) { + if let MenuAction::Close = ev.event().action { + ev.propagate(false); + commands.entity(ev.source).despawn(); + } + } +} + +/// Plugin that adds the observers for the [`CoreMenuItem`] widget. +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); + } +} diff --git a/crates/bevy_ui_widgets/src/popover.rs b/crates/bevy_ui_widgets/src/popover.rs new file mode 100644 index 0000000000000..73591f715dca2 --- /dev/null +++ b/crates/bevy_ui_widgets/src/popover.rs @@ -0,0 +1,246 @@ +//! 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 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, 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 = 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(PostUpdate, position_popover.in_set(UiSystems::Prepare)); + } +} diff --git a/examples/ui/feathers.rs b/examples/ui/feathers.rs index dae81bfef153c..78cc973fd6ab8 100644 --- a/examples/ui/feathers.rs +++ b/examples/ui/feathers.rs @@ -4,9 +4,9 @@ use bevy::{ color::palettes, feathers::{ controls::{ - button, checkbox, color_slider, color_swatch, radio, slider, toggle_switch, - ButtonProps, ButtonVariant, ColorChannel, ColorSlider, ColorSliderProps, ColorSwatch, - SliderBaseColor, SliderProps, + button, checkbox, color_slider, color_swatch, menu, menu_button, menu_item, menu_popup, + radio, slider, toggle_switch, ButtonProps, ButtonVariant, ColorChannel, ColorSlider, + ColorSliderProps, ColorSwatch, MenuButtonProps, SliderBaseColor, SliderProps, }, dark_theme::create_dark_theme, rounded_corners::RoundedCorners, @@ -22,6 +22,7 @@ use bevy::{ SliderPrecision, SliderStep, SliderValue, ValueChange, }, }; +use bevy_scene2::SpawnRelatedScenes as _; /// A struct to hold the state of various widgets shown in the demo. #[derive(Resource)] @@ -123,6 +124,40 @@ fn demo_root() -> impl Scene { }) [ (Text::new("Primary") ThemedText) ] ), + ( + menu(|parent| { + parent.spawn_related_scenes::(bsn_list!( + :menu_popup() + [ + ( + :menu_item() + on(|_: On| { + info!("Menu button clicked!"); + }) + [ + (Text("MenuItem") ThemedText) + ] + ), + ( + :menu_item() + on(|_: On| { + info!("Menu button clicked!"); + }) + [ + (Text("MenuItem") ThemedText) + ] + ) + ] + )); + }) [ + ( + :menu_button(MenuButtonProps::default()) + [ + (Text("Menu") ThemedText) + ] + ) + ] + ) ] ), (