|
| 1 | +use alloc::sync::Arc; |
| 2 | + |
| 3 | +use bevy_app::{Plugin, PreUpdate}; |
| 4 | +use bevy_camera::visibility::Visibility; |
| 5 | +use bevy_color::{Alpha, Srgba}; |
| 6 | +use bevy_ecs::{ |
| 7 | + component::Component, |
| 8 | + entity::Entity, |
| 9 | + hierarchy::Children, |
| 10 | + lifecycle::RemovedComponents, |
| 11 | + observer::On, |
| 12 | + query::{Added, Changed, Has, Or, With}, |
| 13 | + schedule::IntoScheduleConfigs, |
| 14 | + system::{Commands, EntityCommands, Query}, |
| 15 | +}; |
| 16 | +use bevy_log::info; |
| 17 | +use bevy_picking::{ |
| 18 | + events::{Click, Pointer}, |
| 19 | + hover::Hovered, |
| 20 | + PickingSystems, |
| 21 | +}; |
| 22 | +use bevy_scene2::{prelude::*, template_value}; |
| 23 | +use bevy_ui::{ |
| 24 | + AlignItems, BoxShadow, Display, FlexDirection, GlobalZIndex, InteractionDisabled, |
| 25 | + JustifyContent, Node, OverrideClip, PositionType, Pressed, UiRect, Val, |
| 26 | +}; |
| 27 | +use bevy_ui_widgets::{ |
| 28 | + popover::{Popover, PopoverAlign, PopoverPlacement, PopoverSide}, |
| 29 | + MenuAction, MenuEvent, MenuItem, MenuPopup, |
| 30 | +}; |
| 31 | + |
| 32 | +use crate::{ |
| 33 | + constants::{fonts, icons, size}, |
| 34 | + controls::{button, ButtonProps, ButtonVariant}, |
| 35 | + font_styles::InheritableFont, |
| 36 | + icon, |
| 37 | + rounded_corners::RoundedCorners, |
| 38 | + theme::{ThemeBackgroundColor, ThemeBorderColor, ThemeFontColor}, |
| 39 | + tokens, |
| 40 | +}; |
| 41 | +use bevy_input_focus::tab_navigation::TabIndex; |
| 42 | + |
| 43 | +/// Parameters for the menu button template, passed to [`menu_button`] function. |
| 44 | +#[derive(Default)] |
| 45 | +pub struct MenuButtonProps { |
| 46 | + /// Rounded corners options |
| 47 | + pub corners: RoundedCorners, |
| 48 | +} |
| 49 | + |
| 50 | +/// Marker for menu items |
| 51 | +#[derive(Component, Default, Clone)] |
| 52 | +struct MenuItemStyle; |
| 53 | + |
| 54 | +/// Marker for menu popup |
| 55 | +#[derive(Component, Default, Clone)] |
| 56 | +struct MenuPopupStyle; |
| 57 | + |
| 58 | +/// Marker for menu wrapper |
| 59 | +#[derive(Component, Clone, Default)] |
| 60 | +struct Menu(Option<Arc<dyn Fn(EntityCommands) + 'static + Send + Sync>>); |
| 61 | + |
| 62 | +/// Menu scene function. This wraps the menu button and provides an anchor for the popopver. |
| 63 | +pub fn menu<F: Fn(EntityCommands) + 'static + Send + Sync>(spawn_popover: F) -> impl Scene { |
| 64 | + let menu = Menu(Some(Arc::new(spawn_popover))); |
| 65 | + bsn! { |
| 66 | + Node { |
| 67 | + height: size::ROW_HEIGHT, |
| 68 | + justify_content: JustifyContent::Stretch, |
| 69 | + align_items: AlignItems::Stretch, |
| 70 | + } |
| 71 | + template_value(menu) |
| 72 | + on(| |
| 73 | + ev: On<MenuEvent>, |
| 74 | + q_menu: Query<(&Menu, &Children)>, |
| 75 | + q_popovers: Query<Entity, With<MenuPopupStyle>>, |
| 76 | + // mut redraw_events: MessageWriter<RequestRedraw>, |
| 77 | + mut commands: Commands| { |
| 78 | + match ev.event().action { |
| 79 | + // MenuEvent::Open => todo!(), |
| 80 | + // MenuEvent::Close => todo!(), |
| 81 | + MenuAction::Toggle => { |
| 82 | + let mut was_open = false; |
| 83 | + let Ok((menu, children)) = q_menu.get(ev.source) else { |
| 84 | + return; |
| 85 | + }; |
| 86 | + for child in children.iter() { |
| 87 | + if q_popovers.contains(*child) { |
| 88 | + commands.entity(*child).despawn(); |
| 89 | + was_open = true; |
| 90 | + } |
| 91 | + } |
| 92 | + // Spawn the menu if not already open. |
| 93 | + if !was_open { |
| 94 | + info!("Opening, !was_open"); |
| 95 | + if let Some(factory) = menu.0.as_ref() { |
| 96 | + (*factory)(commands.entity(ev.source)); |
| 97 | + // redraw_events.write(RequestRedraw); |
| 98 | + } |
| 99 | + } |
| 100 | + }, |
| 101 | + MenuAction::CloseAll => { |
| 102 | + let Ok((_menu, children)) = q_menu.get(ev.source) else { |
| 103 | + return; |
| 104 | + }; |
| 105 | + for child in children.iter() { |
| 106 | + if q_popovers.contains(*child) { |
| 107 | + commands.entity(*child).despawn(); |
| 108 | + } |
| 109 | + } |
| 110 | + }, |
| 111 | + // MenuEvent::FocusRoot => todo!(), |
| 112 | + event => { |
| 113 | + info!("Menu Event: {:?}", event); |
| 114 | + } |
| 115 | + } |
| 116 | + }) |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +/// Button scene function. |
| 121 | +/// |
| 122 | +/// # Arguments |
| 123 | +/// * `props` - construction properties for the button. |
| 124 | +pub fn menu_button(props: MenuButtonProps) -> impl Scene { |
| 125 | + bsn! { |
| 126 | + :button(ButtonProps { |
| 127 | + variant: ButtonVariant::Normal, |
| 128 | + corners: props.corners, |
| 129 | + }) |
| 130 | + Node { |
| 131 | + // TODO: HACK to deal with lack of intercepted children |
| 132 | + flex_direction: FlexDirection::RowReverse, |
| 133 | + } |
| 134 | + on(|ev: On<Pointer<Click>>, mut commands: Commands| { |
| 135 | + commands.trigger(MenuEvent { source: ev.entity, action: MenuAction::Toggle }); |
| 136 | + }) |
| 137 | + [ |
| 138 | + :icon(icons::CHEVRON_DOWN), |
| 139 | + Node { |
| 140 | + flex_grow: 0.2, |
| 141 | + } |
| 142 | + ] |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +/// Menu Popup scene function |
| 147 | +pub fn menu_popup() -> impl Scene { |
| 148 | + bsn! { |
| 149 | + Node { |
| 150 | + position_type: PositionType::Absolute, |
| 151 | + display: Display::Flex, |
| 152 | + flex_direction: FlexDirection::Column, |
| 153 | + justify_content: JustifyContent::Stretch, |
| 154 | + align_items: AlignItems::Stretch, |
| 155 | + border: UiRect::all(Val::Px(1.0)), |
| 156 | + padding: UiRect::all(Val::Px(4.0)), |
| 157 | + } |
| 158 | + MenuPopupStyle |
| 159 | + MenuPopup |
| 160 | + template_value(Visibility::Hidden) |
| 161 | + template_value(RoundedCorners::All.to_border_radius(4.0)) |
| 162 | + ThemeBackgroundColor(tokens::MENU_BG) |
| 163 | + ThemeBorderColor(tokens::MENU_BORDER) |
| 164 | + BoxShadow::new( |
| 165 | + Srgba::BLACK.with_alpha(0.9).into(), |
| 166 | + Val::Px(0.0), |
| 167 | + Val::Px(0.0), |
| 168 | + Val::Px(1.0), |
| 169 | + Val::Px(4.0), |
| 170 | + ) |
| 171 | + GlobalZIndex(100) |
| 172 | + template_value( |
| 173 | + Popover { |
| 174 | + positions: vec![ |
| 175 | + PopoverPlacement { |
| 176 | + side: PopoverSide::Bottom, |
| 177 | + align: PopoverAlign::Start, |
| 178 | + gap: 2.0, |
| 179 | + }, |
| 180 | + PopoverPlacement { |
| 181 | + side: PopoverSide::Top, |
| 182 | + align: PopoverAlign::Start, |
| 183 | + gap: 2.0, |
| 184 | + }, |
| 185 | + ], |
| 186 | + window_margin: 10.0, |
| 187 | + } |
| 188 | + ) |
| 189 | + OverrideClip |
| 190 | + } |
| 191 | +} |
| 192 | + |
| 193 | +/// Menu item scene function |
| 194 | +pub fn menu_item() -> impl Scene { |
| 195 | + bsn! { |
| 196 | + Node { |
| 197 | + height: size::ROW_HEIGHT, |
| 198 | + min_width: size::ROW_HEIGHT, |
| 199 | + justify_content: JustifyContent::Start, |
| 200 | + align_items: AlignItems::Center, |
| 201 | + padding: UiRect::axes(Val::Px(8.0), Val::Px(0.)), |
| 202 | + } |
| 203 | + MenuItemStyle |
| 204 | + MenuItem |
| 205 | + Hovered |
| 206 | + // TODO: port CursonIcon to GetTemplate |
| 207 | + // CursorIcon::System(bevy_window::SystemCursorIcon::Pointer) |
| 208 | + TabIndex(0) |
| 209 | + ThemeBackgroundColor(tokens::MENU_BG) // Same as menu |
| 210 | + ThemeFontColor(tokens::MENUITEM_TEXT) |
| 211 | + InheritableFont { |
| 212 | + font: fonts::REGULAR, |
| 213 | + font_size: 14.0, |
| 214 | + } |
| 215 | + } |
| 216 | +} |
| 217 | + |
| 218 | +fn update_menuitem_styles( |
| 219 | + q_menuitems: Query< |
| 220 | + ( |
| 221 | + Entity, |
| 222 | + Has<InteractionDisabled>, |
| 223 | + Has<Pressed>, |
| 224 | + &Hovered, |
| 225 | + &ThemeBackgroundColor, |
| 226 | + &ThemeFontColor, |
| 227 | + ), |
| 228 | + ( |
| 229 | + With<MenuItemStyle>, |
| 230 | + Or<(Changed<Hovered>, Added<Pressed>, Added<InteractionDisabled>)>, |
| 231 | + ), |
| 232 | + >, |
| 233 | + mut commands: Commands, |
| 234 | +) { |
| 235 | + for (button_ent, disabled, pressed, hovered, bg_color, font_color) in q_menuitems.iter() { |
| 236 | + set_menuitem_colors( |
| 237 | + button_ent, |
| 238 | + disabled, |
| 239 | + pressed, |
| 240 | + hovered.0, |
| 241 | + bg_color, |
| 242 | + font_color, |
| 243 | + &mut commands, |
| 244 | + ); |
| 245 | + } |
| 246 | +} |
| 247 | + |
| 248 | +fn update_menuitem_styles_remove( |
| 249 | + q_menuitems: Query< |
| 250 | + ( |
| 251 | + Entity, |
| 252 | + Has<InteractionDisabled>, |
| 253 | + Has<Pressed>, |
| 254 | + &Hovered, |
| 255 | + &ThemeBackgroundColor, |
| 256 | + &ThemeFontColor, |
| 257 | + ), |
| 258 | + With<MenuItemStyle>, |
| 259 | + >, |
| 260 | + mut removed_disabled: RemovedComponents<InteractionDisabled>, |
| 261 | + mut removed_pressed: RemovedComponents<Pressed>, |
| 262 | + mut commands: Commands, |
| 263 | +) { |
| 264 | + removed_disabled |
| 265 | + .read() |
| 266 | + .chain(removed_pressed.read()) |
| 267 | + .for_each(|ent| { |
| 268 | + if let Ok((button_ent, disabled, pressed, hovered, bg_color, font_color)) = |
| 269 | + q_menuitems.get(ent) |
| 270 | + { |
| 271 | + set_menuitem_colors( |
| 272 | + button_ent, |
| 273 | + disabled, |
| 274 | + pressed, |
| 275 | + hovered.0, |
| 276 | + bg_color, |
| 277 | + font_color, |
| 278 | + &mut commands, |
| 279 | + ); |
| 280 | + } |
| 281 | + }); |
| 282 | +} |
| 283 | + |
| 284 | +fn set_menuitem_colors( |
| 285 | + button_ent: Entity, |
| 286 | + disabled: bool, |
| 287 | + pressed: bool, |
| 288 | + hovered: bool, |
| 289 | + bg_color: &ThemeBackgroundColor, |
| 290 | + font_color: &ThemeFontColor, |
| 291 | + commands: &mut Commands, |
| 292 | +) { |
| 293 | + let bg_token = match (pressed, hovered) { |
| 294 | + (true, _) => tokens::MENUITEM_BG_PRESSED, |
| 295 | + (false, true) => tokens::MENUITEM_BG_HOVER, |
| 296 | + (false, false) => tokens::MENU_BG, |
| 297 | + }; |
| 298 | + |
| 299 | + let font_color_token = match disabled { |
| 300 | + true => tokens::MENUITEM_TEXT_DISABLED, |
| 301 | + false => tokens::MENUITEM_TEXT, |
| 302 | + }; |
| 303 | + |
| 304 | + // Change background color |
| 305 | + if bg_color.0 != bg_token { |
| 306 | + commands |
| 307 | + .entity(button_ent) |
| 308 | + .insert(ThemeBackgroundColor(bg_token)); |
| 309 | + } |
| 310 | + |
| 311 | + // Change font color |
| 312 | + if font_color.0 != font_color_token { |
| 313 | + commands |
| 314 | + .entity(button_ent) |
| 315 | + .insert(ThemeFontColor(font_color_token)); |
| 316 | + } |
| 317 | +} |
| 318 | + |
| 319 | +/// Plugin which registers the systems for updating the menu and menu button styles. |
| 320 | +pub struct MenuPlugin; |
| 321 | + |
| 322 | +impl Plugin for MenuPlugin { |
| 323 | + fn build(&self, app: &mut bevy_app::App) { |
| 324 | + app.add_systems( |
| 325 | + PreUpdate, |
| 326 | + (update_menuitem_styles, update_menuitem_styles_remove).in_set(PickingSystems::Last), |
| 327 | + ); |
| 328 | + } |
| 329 | +} |
0 commit comments