More poking at Floating.

This commit is contained in:
Talin 2025-06-25 14:30:38 -07:00
parent 588989a4d0
commit 8ed8666cbf
4 changed files with 127 additions and 16 deletions

View File

@ -17,7 +17,8 @@ use crate::portal::{PortalTraversal, PortalTraversalItem};
///
/// 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.
/// 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.
@ -28,6 +29,10 @@ pub enum MenuEvent {
/// Move the input focs to the parent element. This usually happens as the menu is closing,
/// although will not happen if the close was a result of clicking on the background.
FocusParent,
/// 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).
@ -63,16 +68,16 @@ impl Traversal<MenuEvent> for PortalTraversal {
}
}
/// Component that defines a popup menu
/// Component that defines a popup menu container.
#[derive(Component, Debug)]
#[require(AccessibilityNode(accesskit::Node::new(Role::MenuListPopup)))]
pub struct CoreMenuPopup;
/// Component that defines a menu item
/// Component that defines a menu item.
#[derive(Component, Debug)]
#[require(AccessibilityNode(accesskit::Node::new(Role::MenuItem)))]
pub struct CoreMenuItem {
/// Optional system to run when the menu item is clicked, or when the Enter or Space key
/// is pressed while the button is focused.
/// is pressed while the item is focused.
pub on_click: Option<SystemId>,
}

View File

@ -1,9 +1,14 @@
//! Framework for positioning of popups, tooltips, and other floating UI elements.
use bevy_app::{App, Plugin, PostUpdate};
use bevy_ecs::{component::Component, entity::Entity, query::Without, system::Query};
use bevy_app::{App, Plugin, PreUpdate};
use bevy_ecs::{
component::Component, entity::Entity, query::Without, schedule::IntoScheduleConfigs,
system::Query,
};
use bevy_math::{Rect, Vec2};
use bevy_ui::{ComputedNode, ComputedNodeTarget, Node, UiGlobalTransform, Val};
use bevy_ui::{
ComputedNode, ComputedNodeTarget, Node, PositionType, UiGlobalTransform, UiSystems, Val,
};
/// Which side of the anchor element the floating element should be placed.
#[derive(Debug, Default, Clone, Copy, PartialEq)]
@ -102,6 +107,11 @@ fn position_floating(
q_anchor: Query<(&ComputedNode, &UiGlobalTransform), Without<Floating>>,
) {
for (mut node, computed_node, computed_target, floating) in q_float.iter_mut() {
// Logical size isn't set initially, ignore until it is.
if computed_target.logical_size().length_squared() == 0.0 {
continue;
}
// A rectangle which represents the area of the window.
let window_rect = Rect {
min: Vec2::ZERO,
@ -114,7 +124,10 @@ fn position_floating(
let Ok((anchor_node, anchor_transform)) = q_anchor.get(anchor_entity) else {
continue;
};
Rect::from_center_size(anchor_transform.translation, anchor_node.size())
Rect::from_center_size(
anchor_transform.translation * anchor_node.inverse_scale_factor,
anchor_node.size() * anchor_node.inverse_scale_factor,
)
}
FloatAnchor::Rect(rect) => rect,
};
@ -125,7 +138,7 @@ fn position_floating(
// Loop through all the potential positions and find a good one.
for position in &floating.positions {
let float_size = computed_node.size();
let float_size = computed_node.size() * computed_node.inverse_scale_factor;
let mut rect = Rect::default();
// Taraget width and height depends on whether 'stretch' is true.
@ -225,6 +238,7 @@ fn position_floating(
if best_occluded < f32::MAX {
node.left = Val::Px(best_rect.min.x);
node.top = Val::Px(best_rect.min.y);
node.position_type = PositionType::Absolute;
if best_position.stretch {
match best_position.side {
FloatSide::Top | FloatSide::Bottom => {
@ -245,6 +259,6 @@ pub struct FloatingPlugin;
impl Plugin for FloatingPlugin {
fn build(&self, app: &mut App) {
app.add_systems(PostUpdate, position_floating);
app.add_systems(PreUpdate, position_floating.in_set(UiSystems::Prepare));
}
}

View File

@ -1,13 +1,19 @@
//! Relationships for defining "portal children", where the term "portal" refers to a mechanism
//! whereby a logical child node can be physically located at a different point in the hierarchy.
//! The "portal" represents a logical connection between the child and it's parent which is not
//! a normal child relationship.
//! Relationships for defining "portal children".
//!
//! The term "portal" is commonly used in web user interface libraries to mean a mechanism whereby a
//! parent element can have a logical child which is physically present elsewhere in the hierarchy.
//! In this case, it means that for rendering and layout purposes, the child acts as a root node,
//! but for purposes of event bubbling and ownership, it acts as a child.
//!
//! This is typically used for UI elements such as menus and dialogs which need to calculate their
//! positions in window coordinates, despite being owned by UI elements nested deep within the
//! hierarchy.
use bevy_ecs::{component::Component, entity::Entity, hierarchy::ChildOf, query::QueryData};
/// Defines the portal child relationship. For purposes of despawning, a portal child behaves
/// as if it's a real child. However, for purpose of rendering and layout, a portal child behaves
/// as if it's a root element. Certain events can also bubble via the portal relationship.
/// as if it's a root element. Certain events can also bubble through the portal relationship.
#[derive(Component, Clone, PartialEq, Eq, Debug)]
#[relationship(relationship_target = PortalChildren)]
pub struct PortalChildOf(#[entities] pub Entity);
@ -25,7 +31,7 @@ impl PortalChildOf {
#[relationship_target(relationship = PortalChildOf, linked_spawn)]
pub struct PortalChildren(Vec<Entity>);
/// A traversal that uses either the [`ChildOf`] or [`PortalChildOf`] relationship. If the
/// A traversal algorithm that uses either the [`ChildOf`] or [`PortalChildOf`] relationship. If the
/// entity has both relations, the latter takes precedence.
#[derive(QueryData)]
pub struct PortalTraversal {

View File

@ -78,6 +78,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 +136,8 @@ fn setup(mut commands: Commands, assets: Res<AssetServer>) {
},
);
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<Entity>,
@ -213,6 +219,49 @@ fn button(asset_server: &AssetServer, on_click: Callback) -> impl Bundle {
)
}
fn menu_button(asset_server: &AssetServer, on_click: SystemId) -> impl Bundle {
(
Node {
width: Val::Px(200.0),
height: Val::Px(65.0),
border: UiRect::all(Val::Px(5.0)),
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(16.0), Val::Px(0.0)),
..default()
},
DemoMenuButton,
CoreButton {
on_click: Callback::System(on_click),
},
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 +788,43 @@ fn radio(asset_server: &AssetServer, value: TrackClick, caption: &str) -> impl B
)
}
fn spawn_popup(menu: Query<Entity, With<DemoMenuButton>>, mut commands: Commands) {
let Ok(anchor) = menu.single() else {
return;
};
commands.entity(anchor).insert(PortalChildren::spawn_one((
Node {
min_height: Val::Px(100.),
min_width: Val::Px(100.),
border: UiRect::all(Val::Px(2.0)),
position_type: PositionType::Absolute,
left: Val::Px(100.),
..default()
},
BorderColor::all(GREEN.into()),
BackgroundColor(GRAY.into()),
ZIndex(100),
Floating {
anchor: FloatAnchor::Node(anchor),
positions: vec![
FloatPosition {
side: FloatSide::Bottom,
align: FloatAlign::Start,
gap: 2.0,
..default()
},
FloatPosition {
side: FloatSide::Top,
align: FloatAlign::Start,
gap: 2.0,
..default()
},
],
},
)));
info!("Open menu");
}
fn toggle_disabled(
input: Res<ButtonInput<KeyCode>>,
mut interaction_query: Query<