Path: blob/main/crates/bevy_feathers/src/controls/menu.rs
30636 views
use bevy_app::{Plugin, PreUpdate};1use bevy_camera::visibility::Visibility;2use bevy_color::{Alpha, Srgba};3use bevy_ecs::{4change_detection::DetectChanges,5entity::Entity,6hierarchy::Children,7lifecycle::RemovedComponents,8observer::On,9query::{Added, Changed, Has, Or, With},10schedule::IntoScheduleConfigs,11system::{Commands, Query, Res, ResMut},12};13use bevy_log::warn;14use bevy_picking::{hover::Hovered, PickingSystems};15use bevy_scene::prelude::*;16use bevy_text::FontWeight;17use bevy_ui::{18px, AlignItems, AlignSelf, BoxShadow, Display, FlexDirection, GlobalZIndex,19InteractionDisabled, JustifyContent, Node, OverrideClip, PositionType, Pressed, UiRect,20};21use bevy_ui_widgets::{22popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide},23ActivateOnPress, MenuAction, MenuButton, MenuEvent, MenuFocusState, MenuItem, MenuPopup,24};2526use crate::{27constants::{fonts, icons, size},28controls::{ButtonVariant, FeathersButton},29cursor::EntityCursor,30display::icon,31font_styles::InheritableFont,32rounded_corners::RoundedCorners,33theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor},34tokens,35};36use bevy_input_focus::{37tab_navigation::{NavAction, TabIndex},38FocusCause, InputFocus, InputFocusVisible,39};4041/// Top-level menu container. This wraps the menu button and provides an anchor for the popover.42///43/// This is spawnable by inheriting it as a "scene component".44#[derive(SceneComponent, Clone, Default)]45pub struct FeathersMenu;4647impl FeathersMenu {48fn scene() -> impl Scene {49bsn! {50Node {51height: size::ROW_HEIGHT,52justify_content: JustifyContent::Stretch,53align_items: AlignItems::Stretch,54}55FeathersMenu56on(on_menu_event)57}58}59}6061fn on_menu_event(62mut ev: On<MenuEvent>,63q_menu_children: Query<&Children>,64q_popovers: Query<&mut Visibility, With<FeathersMenuPopup>>,65q_buttons: Query<(), With<FeathersMenuButton>>,66mut commands: Commands,67mut focus: ResMut<InputFocus>,68) {69match ev.event().action {70MenuAction::Open(nav) => {71let Ok(children) = q_menu_children.get(ev.source) else {72return;73};74ev.propagate(false);75for child in children.iter() {76if q_popovers.contains(*child) {77commands78.entity(*child)79.insert((Visibility::Visible, MenuFocusState::Opening(nav)));80return;81}82}83warn!("Menu popup not found");84}85MenuAction::Toggle => {86let Ok(children) = q_menu_children.get(ev.source) else {87return;88};89for child in children.iter() {90if let Ok(visibility) = q_popovers.get(*child) {91ev.propagate(false);92if visibility == Visibility::Visible {93commands.entity(*child).insert(Visibility::Hidden);94} else {95commands.entity(*child).insert((96Visibility::Visible,97MenuFocusState::Opening(NavAction::First),98));99}100return;101}102}103warn!("Menu popup not found");104}105MenuAction::CloseAll => {106let Ok(children) = q_menu_children.get(ev.source) else {107return;108};109for child in children.iter() {110if q_popovers.contains(*child) {111ev.propagate(false);112commands.entity(*child).insert(Visibility::Hidden);113}114}115}116MenuAction::FocusRoot => {117let Ok(children) = q_menu_children.get(ev.source) else {118return;119};120for child in children.iter() {121if q_buttons.contains(*child) {122ev.propagate(false);123focus.set(*child, FocusCause::Navigated);124break;125}126}127}128}129}130131/// A menu button widget. This produces a button that has a dropdown arrow.132///133/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersMenuButtonProps`].134#[derive(SceneComponent, Default, Clone)]135#[scene(FeathersMenuButtonProps)]136pub struct FeathersMenuButton;137138/// Props used to construct a [`FeathersMenuButton`] scene.139pub struct FeathersMenuButtonProps {140/// Label for this menu button141pub caption: Box<dyn SceneList>,142/// Rounded corners options143pub corners: RoundedCorners,144/// Include the standard downward-pointing chevron (default true).145pub arrow: bool,146}147148impl Default for FeathersMenuButtonProps {149fn default() -> Self {150Self {151caption: Box::new(bsn_list!()),152corners: Default::default(),153arrow: true,154}155}156}157impl FeathersMenuButton {158fn scene(props: FeathersMenuButtonProps) -> impl Scene {159bsn! {160@FeathersButton {161@caption: {props.caption},162@variant: ButtonVariant::Normal,163@corners: {props.corners},164}165ActivateOnPress166MenuButton167FeathersMenuButton168// Additional children for menu chevron169Children [170{171if props.arrow {172Box::new(bsn_list!(173Node {174flex_grow: 1.0,175},176icon(icons::CHEVRON_DOWN),177)) as Box<dyn SceneList>178} else {179Box::new(bsn_list!()) as Box<dyn SceneList>180}181}182]183}184}185}186187/// A menu popup widget.188#[derive(SceneComponent, Default, Clone)]189pub struct FeathersMenuPopup;190191impl FeathersMenuPopup {192fn scene() -> impl Scene {193bsn! {194Node {195position_type: PositionType::Absolute,196display: Display::Flex,197flex_direction: FlexDirection::Column,198justify_content: JustifyContent::Stretch,199align_items: AlignItems::Stretch,200border: px(1),201padding: UiRect::axes(px(0), px(4)),202border_radius: {RoundedCorners::All.to_border_radius(4.0)},203}204FeathersMenuPopup205MenuPopup206Visibility::Hidden207ThemeBackgroundColor(tokens::MENU_BG)208ThemeBorderColor(tokens::MENU_BORDER)209BoxShadow::new(210Srgba::BLACK.with_alpha(0.9).into(),211px(0),212px(0),213px(1),214px(4),215)216GlobalZIndex(100)217Popover {218positions: {vec![219PopoverPlacement {220side: PopoverSide::Bottom,221align: PopoverAlign::Start,222gap: 2.0,223},224PopoverPlacement {225side: PopoverSide::Top,226align: PopoverAlign::Start,227gap: 2.0,228},229]},230window_margin: 10.0,231}232OverrideClip233}234}235}236237/// A menu item widget.238///239/// This is spawnable by inheriting it as a "scene component" with optional [`FeathersMenuItemProps`].240#[derive(SceneComponent, Default, Clone)]241#[scene(FeathersMenuItemProps)]242pub struct FeathersMenuItem;243244/// Props used to construct a [`FeathersMenuItem`] scene.245pub struct FeathersMenuItemProps {246/// Label for this menu item247pub caption: Box<dyn SceneList>,248}249250impl Default for FeathersMenuItemProps {251fn default() -> Self {252Self {253caption: Box::new(bsn_list!()),254}255}256}257258impl FeathersMenuItem {259fn scene(props: FeathersMenuItemProps) -> impl Scene {260bsn! {261Node {262height: size::ROW_HEIGHT,263min_width: size::ROW_HEIGHT,264justify_content: JustifyContent::Start,265align_items: AlignItems::Center,266padding: UiRect::horizontal(px(8)),267}268FeathersMenuItem269MenuItem270Hovered271EntityCursor::System(bevy_window::SystemCursorIcon::Pointer)272TabIndex(0)273ThemeBackgroundColor(tokens::MENU_BG) // Same as menu274InheritableThemeTextColor(tokens::MENUITEM_TEXT)275InheritableFont {276font: fonts::REGULAR,277font_size: size::MEDIUM_FONT,278weight: FontWeight::NORMAL,279}280Children [281{props.caption}282]283}284}285}286287fn update_menuitem_styles(288q_menuitems: Query<289(290Entity,291Has<InteractionDisabled>,292Has<Pressed>,293&Hovered,294&ThemeBackgroundColor,295&InheritableThemeTextColor,296),297(298With<FeathersMenuItem>,299Or<(Changed<Hovered>, Added<Pressed>, Added<InteractionDisabled>)>,300),301>,302mut commands: Commands,303focus: Res<InputFocus>,304focus_visible: Res<InputFocusVisible>,305) {306for (item_ent, disabled, pressed, hovered, bg_color, font_color) in q_menuitems.iter() {307set_menuitem_colors(308item_ent,309disabled,310pressed,311hovered.0,312Some(item_ent) == focus.get() && focus_visible.0,313bg_color,314font_color,315&mut commands,316);317}318}319320fn update_menuitem_styles_remove(321q_menuitems: Query<322(323Entity,324Has<InteractionDisabled>,325Has<Pressed>,326&Hovered,327&ThemeBackgroundColor,328&InheritableThemeTextColor,329),330With<FeathersMenuItem>,331>,332mut removed_disabled: RemovedComponents<InteractionDisabled>,333mut removed_pressed: RemovedComponents<Pressed>,334focus: Res<InputFocus>,335focus_visible: Res<InputFocusVisible>,336mut commands: Commands,337) {338removed_disabled339.read()340.chain(removed_pressed.read())341.for_each(|ent| {342if let Ok((item_ent, disabled, pressed, hovered, bg_color, font_color)) =343q_menuitems.get(ent)344{345set_menuitem_colors(346item_ent,347disabled,348pressed,349hovered.0,350Some(item_ent) == focus.get() && focus_visible.0,351bg_color,352font_color,353&mut commands,354);355}356});357}358359fn update_menuitem_styles_focus_changed(360q_menuitems: Query<361(362Entity,363Has<InteractionDisabled>,364Has<Pressed>,365&Hovered,366&ThemeBackgroundColor,367&InheritableThemeTextColor,368),369With<FeathersMenuItem>,370>,371focus: Res<InputFocus>,372focus_visible: Res<InputFocusVisible>,373mut commands: Commands,374) {375if focus.is_changed() || focus_visible.is_changed() {376for (item_ent, disabled, pressed, hovered, bg_color, font_color) in q_menuitems.iter() {377set_menuitem_colors(378item_ent,379disabled,380pressed,381hovered.0,382Some(item_ent) == focus.get() && focus_visible.0,383bg_color,384font_color,385&mut commands,386);387}388}389}390391fn set_menuitem_colors(392button_ent: Entity,393disabled: bool,394pressed: bool,395hovered: bool,396focused: bool,397bg_color: &ThemeBackgroundColor,398font_color: &InheritableThemeTextColor,399commands: &mut Commands,400) {401let bg_token = match (focused, pressed, hovered) {402(true, _, _) => tokens::MENUITEM_BG_FOCUSED,403(false, true, _) => tokens::MENUITEM_BG_PRESSED,404(false, false, true) => tokens::MENUITEM_BG_HOVER,405(false, false, false) => tokens::MENU_BG,406};407408let font_color_token = match disabled {409true => tokens::MENUITEM_TEXT_DISABLED,410false => tokens::MENUITEM_TEXT,411};412413// Change background color414if bg_color.0 != bg_token {415commands416.entity(button_ent)417.insert(ThemeBackgroundColor(bg_token));418}419420// Change font color421if font_color.0 != font_color_token {422commands423.entity(button_ent)424.insert(InheritableThemeTextColor(font_color_token));425}426}427428/// A decorative divider between menu items429#[derive(SceneComponent, Default, Clone)]430pub struct FeathersMenuDivider;431432impl FeathersMenuDivider {433fn scene() -> impl Scene {434bsn! {435Node {436height: px(1),437justify_content: JustifyContent::Start,438align_self: AlignSelf::Stretch,439margin: UiRect::vertical(px(2)),440}441ThemeBackgroundColor(tokens::MENU_BORDER) // Same as menu442}443}444}445446/// Plugin which registers the systems for updating the menu and menu button styles.447pub struct MenuPlugin;448449impl Plugin for MenuPlugin {450fn build(&self, app: &mut bevy_app::App) {451app.add_systems(452PreUpdate,453(454update_menuitem_styles,455update_menuitem_styles_remove,456update_menuitem_styles_focus_changed,457)458.in_set(PickingSystems::Last),459);460}461}462463464