From 98d4303056fe571973c136e5b70c623380a4f513 Mon Sep 17 00:00:00 2001 From: John Payne <20407779+johngpayne@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:59:46 +0100 Subject: [PATCH 1/3] Support floating dialogs in feathers and underlying bevy_ui_widgets. --- crates/bevy_feathers/src/controls/dialog.rs | 105 +++++++++- crates/bevy_ui_widgets/src/dialog.rs | 215 ++++++++++++++++++++ crates/bevy_ui_widgets/src/lib.rs | 3 + crates/bevy_ui_widgets/src/modal.rs | 39 ++-- 4 files changed, 338 insertions(+), 24 deletions(-) create mode 100644 crates/bevy_ui_widgets/src/dialog.rs diff --git a/crates/bevy_feathers/src/controls/dialog.rs b/crates/bevy_feathers/src/controls/dialog.rs index afb458e5c8973..f3353f80eb000 100644 --- a/crates/bevy_feathers/src/controls/dialog.rs +++ b/crates/bevy_feathers/src/controls/dialog.rs @@ -7,17 +7,19 @@ use bevy_reflect::{prelude::ReflectDefault, Reflect}; use bevy_scene::{bsn, bsn_list, on, Scene, SceneComponent, SceneList}; use bevy_text::FontWeight; use bevy_ui::{ - px, vh, vw, AlignItems, BorderRadius, BoxShadow, Display, FixedNode, FlexDirection, - GlobalZIndex, JustifyContent, Node, OverrideClip, PositionType, UiRect, Val, + px, vh, vw, widget::Text, AlignItems, BorderRadius, BoxShadow, Display, FixedNode, + FlexDirection, GlobalZIndex, JustifyContent, Node, OverrideClip, PositionType, UiRect, Val, +}; +use bevy_ui_widgets::{ + Activate, Dialog, DialogDragHandle, ModalDialog, ModalDialogBarrier, RequestClose, }; -use bevy_ui_widgets::{Activate, ModalDialog, ModalDialogBarrier, RequestClose}; use crate::{ constants::{fonts, icons, size}, controls::{ButtonVariant, FeathersToolButton}, display::icon, font_styles::InheritableFont, - theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor}, + theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor, ThemedText}, tokens, }; @@ -93,6 +95,101 @@ impl FeathersDialog { } } +/// Props used to construct a [`FeathersFloatingDialog`] scene. +pub struct FeathersFloatingDialogProps { + /// Title shown in the window's drag bar. + pub title: String, + /// Body content of the window. + pub contents: Box, + /// How wide the window should be. + pub width: Val, + /// Initial left offset (the window is absolutely positioned). + pub left: Val, + /// Initial top offset. + pub top: Val, +} + +impl Default for FeathersFloatingDialogProps { + fn default() -> Self { + Self { + title: String::new(), + contents: Box::new(bsn_list!()), + width: Val::Auto, + left: px(120), + top: px(120), + } + } +} + +/// A non-modal, movable floating dialog with a draggable title bar and a close button. +#[derive(SceneComponent, Default, Clone, Reflect)] +#[scene(FeathersFloatingDialogProps)] +#[reflect(Component, Clone, Default)] +pub struct FeathersFloatingDialog; + +impl FeathersFloatingDialog { + /// Scene function for a floating window. + pub fn scene(props: FeathersFloatingDialogProps) -> impl Scene { + bsn! { + Node { + display: Display::Flex, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Stretch, + position_type: PositionType::Absolute, + left: {props.left}, + top: {props.top}, + border_radius: BorderRadius::all(px(4)), + border: UiRect::all(px(1.0)), + width: {props.width}, + } + Dialog + ThemeBackgroundColor(tokens::DIALOG_BG) + ThemeBorderColor(tokens::DIALOG_BORDER) + InheritableThemeTextColor(tokens::DIALOG_TEXT) + BoxShadow::new( + Srgba::BLACK.with_alpha(0.9).into(), + px(0), + px(0), + px(1), + px(4), + ) + // Closing despawns the window. + on(|close: On, mut commands: Commands| { + commands.entity(close.event_target()).despawn(); + }) + Children [ + // Title bar; dragging it moves the window. + ( + Node { + display: Display::Flex, + flex_direction: FlexDirection::Row, + align_items: AlignItems::Center, + justify_content: JustifyContent::SpaceBetween, + padding: UiRect::all(px(6.0)), + } + DialogDragHandle + ThemeBackgroundColor(tokens::DIALOG_HEADER_BG) + InheritableFont { + font: fonts::REGULAR, + font_size: size::HEADER_FONT, + weight: FontWeight::BOLD, + } + Children [ + (Text({props.title}) ThemedText), + @FeathersDialogClose + ] + ), + ( + @FeathersDialogBody + Children [ + {props.contents} + ] + ) + ] + } + } +} + /// Header section for a modal dialog #[derive(SceneComponent, Default, Clone, Reflect)] #[reflect(Component, Clone, Default)] diff --git a/crates/bevy_ui_widgets/src/dialog.rs b/crates/bevy_ui_widgets/src/dialog.rs new file mode 100644 index 0000000000000..310d46ed6387d --- /dev/null +++ b/crates/bevy_ui_widgets/src/dialog.rs @@ -0,0 +1,215 @@ +use accesskit::Role; +use bevy_a11y::AccessibilityNode; +use bevy_app::{App, Plugin, Update}; +use bevy_ecs::{ + component::Component, + entity::Entity, + event::EntityEvent, + hierarchy::ChildOf, + lifecycle::{Add, Remove}, + observer::On, + query::{With, Without}, + reflect::{ReflectComponent, ReflectEvent}, + resource::Resource, + schedule::{common_conditions::resource_changed, IntoScheduleConfigs}, + system::{Commands, Query, Res, ResMut}, +}; +use bevy_input_focus::tab_navigation::TabGroup; +use bevy_picking::{ + events::{Drag, DragStart, Pointer, Press}, + pointer::PointerButton, +}; +use bevy_reflect::Reflect; +use bevy_ui::{GlobalZIndex, UiScale, UiTransform, Val2}; + +use crate::ModalDialog; + +/// A dialog box. When `ModalDialog` is also present it traps focus and is backed by a +/// [`ModalDialogBarrier`]; when false it is a movable, non-blocking floating window. +#[derive(Component, Debug, Reflect, Clone, Default)] +#[require(AccessibilityNode(accesskit::Node::new(Role::Dialog)))] +#[require(TabGroup { order: 0, modal: false })] +#[reflect(Component)] +pub struct Dialog; + +/// Event used to indicate that the dialog wants to be closed. This can happen because +/// the user clicked on the barrier, hit the escape key, or clicked the close box in the dialog +/// title. This event propagates so that the owner of the dialog can despawn it. +#[derive(EntityEvent, Clone, Debug)] +#[entity_event(propagate, auto_propagate)] +#[derive(Reflect)] +#[reflect(Event)] +pub struct RequestClose { + /// The [`Dialog`] that triggered this event. + #[event_target] + pub source: Entity, +} + +const FLOATING_Z_BASE: i32 = 50; +const FLOATING_Z_MAX: i32 = 89; + +/// Marks a region (e.g. a title bar) that drags its owning [`Dialog`]. +#[derive(Component, Debug, Reflect, Clone, Default)] +#[require(DialogDragState)] +#[reflect(Component)] +pub struct DialogDragHandle; + +/// Records the owning dialog's translation when a drag begins, so each drag event +/// can set an absolute position from the drag's cumulative distance. +#[derive(Component, Debug, Reflect, Clone, Default)] +#[reflect(Component)] +pub struct DialogDragState { + /// The owning dialog's `UiTransform.translation` + start: Val2, +} + +/// Open floating dialogs, ordered bottom-to-top; the front-most is last. +#[derive(Resource, Default)] +pub struct DialogStack(Vec); + +impl DialogStack { + /// Add (or move) a dialog to the front of the stack. + fn push_top(&mut self, entity: Entity) { + self.0.retain(|&e| e != entity); + self.0.push(entity); + } + + /// Remove a dialog from the stack. + fn remove(&mut self, entity: Entity) { + self.0.retain(|&e| e != entity); + } +} + +/// Track newly-spawned floating dialogs at the top of the stack. +fn register_dialog( + add: On, + q_dialog: Query<&Dialog, Without>, + mut stack: ResMut, +) { + let entity = add.event_target(); + if q_dialog.contains(entity) { + stack.push_top(entity); + } +} + +/// Drop dialogs from the stack when they despawn. +fn deregister_dialog( + remove: On, + q_dialog: Query<&Dialog, Without>, + mut stack: ResMut, +) { + let entity = remove.event_target(); + if q_dialog.contains(entity) { + stack.remove(entity); + } +} + +/// Give each floating dialog a `GlobalZIndex` from its stack position, so order +/// drives both draw order and pointer-pick order. +fn sync_dialog_z(stack: Res, mut commands: Commands) { + for (index, &entity) in stack.0.iter().enumerate() { + let z = (FLOATING_Z_BASE + index as i32).min(FLOATING_Z_MAX); + commands.entity(entity).insert(GlobalZIndex(z)); + } +} + +/// Raise a floating dialog to the front of the stack when it is pressed. +fn bring_to_front( + press: On>, + q_dialog: Query<&Dialog, Without>, + q_parent: Query<&ChildOf>, + mut stack: ResMut, +) { + let target = press.event_target(); + let Some(dialog) = core::iter::once(target) + .chain(q_parent.iter_ancestors(target)) + .find(|&e| q_dialog.contains(e)) + else { + return; + }; + // Already on top. + if stack.0.last() == Some(&dialog) { + return; + } + stack.push_top(dialog); +} + +/// Record the dialog's translation when a drag begins on its [`DialogDragHandle`]. +fn dialog_drag_start( + drag_start: On>, + q_dialog: Query<(), With>, + q_parent: Query<&ChildOf>, + q_transform: Query<&UiTransform>, + mut q_state: Query<&mut DialogDragState>, +) { + if drag_start.button != PointerButton::Primary { + return; + } + // Only the handle entity itself drives the move. + let handle = drag_start.event_target(); + let Ok(mut state) = q_state.get_mut(handle) else { + return; + }; + let Some(dialog) = q_parent + .iter_ancestors(handle) + .find(|&e| q_dialog.contains(e)) + else { + return; + }; + state.start = q_transform + .get(dialog) + .map(|t| t.translation) + .expect("Cannot get translation from dialog"); +} + +/// Move a dialog by dragging its [`DialogDragHandle`], positioning it at the +/// drag-start translation plus the drag's cumulative distance. +fn dialog_drag( + drag: On>, + q_handle: Query<&DialogDragState, With>, + q_dialog: Query<(), With>, + q_parent: Query<&ChildOf>, + mut q_transform: Query<&mut UiTransform>, + ui_scale: Res, +) { + if drag.button != PointerButton::Primary { + return; + } + let handle = drag.event_target(); + let Ok(state) = q_handle.get(handle) else { + return; + }; + let Some(dialog) = q_parent + .iter_ancestors(handle) + .find(|&e| q_dialog.contains(e)) + else { + return; + }; + // `distance` is in logical pixels; a `Val::Px` translation is scaled by + // `UiScale`, so divide that out. + let offset = drag.distance / ui_scale.0; + if let Ok(mut transform) = q_transform.get_mut(dialog) { + transform.translation = state + .start + .try_add(Val2::px(offset.x, offset.y)) + .expect("Cannot add offset to dialog translation"); + } +} + +/// Plugin that adds the observers and systems for the [`Dialog`] widget. +pub struct DialogPlugin; + +impl Plugin for DialogPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_observer(register_dialog) + .add_observer(deregister_dialog) + .add_observer(dialog_drag_start) + .add_observer(dialog_drag) + .add_observer(bring_to_front) + .add_systems( + Update, + sync_dialog_z.run_if(resource_changed::), + ); + } +} diff --git a/crates/bevy_ui_widgets/src/lib.rs b/crates/bevy_ui_widgets/src/lib.rs index 64593a59bd03e..75136103fa321 100644 --- a/crates/bevy_ui_widgets/src/lib.rs +++ b/crates/bevy_ui_widgets/src/lib.rs @@ -28,6 +28,7 @@ mod button; mod checkbox; +mod dialog; mod list; mod menu; mod modal; @@ -41,6 +42,7 @@ mod text_input; pub use button::*; pub use checkbox::*; +pub use dialog::*; pub use list::*; pub use menu::*; pub use modal::*; @@ -70,6 +72,7 @@ impl PluginGroup for UiWidgetsPlugins { .add(EditableTextInputPlugin) .add(ListBoxPlugin) .add(MenuPlugin) + .add(DialogPlugin) .add(ModalDialogPlugin) .add(PopoverPlugin) .add(RadioGroupPlugin) diff --git a/crates/bevy_ui_widgets/src/modal.rs b/crates/bevy_ui_widgets/src/modal.rs index e3718302fccf4..093fe6c8b7f3a 100644 --- a/crates/bevy_ui_widgets/src/modal.rs +++ b/crates/bevy_ui_widgets/src/modal.rs @@ -3,13 +3,12 @@ use bevy_a11y::AccessibilityNode; use bevy_app::{App, Plugin}; use bevy_ecs::{ component::Component, - entity::Entity, event::EntityEvent, hierarchy::ChildOf, lifecycle::Add, observer::On, query::With, - reflect::{ReflectComponent, ReflectEvent}, + reflect::ReflectComponent, system::{Commands, Query, SystemState}, world::World, }; @@ -23,10 +22,12 @@ use bevy_picking::events::{Pointer, Press}; use bevy_reflect::Reflect; use bevy_time::DelayedCommandsExt; +use crate::{Dialog, RequestClose}; + /// Component that defines a modal dialog box. #[derive(Component, Debug, Reflect, Clone, Default)] #[require(AccessibilityNode(accesskit::Node::new(Role::Dialog)))] -#[require(TabGroup { order: 0, modal: true })] +#[require(Dialog)] #[reflect(Component)] pub struct ModalDialog; @@ -35,20 +36,17 @@ pub struct ModalDialog; #[reflect(Component)] pub struct ModalDialogBarrier; -/// Event used to indicate that the modal dialog wants to be closed. This can happen because -/// the user clicked on the barrier, hit the escape key, or clicked the close box in the dialog -/// title. This event propagates so that the owner of the dialog can despawn it. -#[derive(EntityEvent, Clone, Debug)] -#[entity_event(propagate, auto_propagate)] -#[derive(Reflect)] -#[reflect(Event)] -pub struct RequestClose { - /// The [`ModalDialog`] that triggered this event. - #[event_target] - pub source: Entity, +fn set_modal_dialog_tab_group_modal( + add: On, + mut q_tab_group: Query<&mut TabGroup>, +) { + let entity = add.event_target(); + if let Ok(mut tab_group) = q_tab_group.get_mut(entity) { + tab_group.modal = true; + } } -fn dialog_barrier_on_click( +fn modal_dialog_barrier_on_click( mut ev: On>, q_barrier: Query<(), With>, q_dialog: Query<(), With>, @@ -66,7 +64,7 @@ fn dialog_barrier_on_click( } } -fn dialog_barrier_on_keypress( +fn modal_dialog_barrier_on_keypress( mut ev: On>, q_barrier: Query<(), With>, mut commands: Commands, @@ -82,7 +80,7 @@ fn dialog_barrier_on_keypress( } } -fn dialog_barrier_on_spawn(add: On, mut commands: Commands) { +fn modal_dialog_barrier_on_spawn(add: On, mut commands: Commands) { let dialog_entity = add.event_target(); // Need to defer setting focus until children are finished spawning. Note that we don't know, // in this module, what API will be used to spawn the dialog, so we have to guess how long @@ -135,8 +133,9 @@ pub struct ModalDialogPlugin; impl Plugin for ModalDialogPlugin { fn build(&self, app: &mut App) { - app.add_observer(dialog_barrier_on_spawn) - .add_observer(dialog_barrier_on_click) - .add_observer(dialog_barrier_on_keypress); + app.add_observer(set_modal_dialog_tab_group_modal) + .add_observer(modal_dialog_barrier_on_spawn) + .add_observer(modal_dialog_barrier_on_click) + .add_observer(modal_dialog_barrier_on_keypress); } } From ac625c9f6e1b13bdf5f17e029345df9e6a84248c Mon Sep 17 00:00:00 2001 From: John Payne <20407779+johngpayne@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:27:57 +0100 Subject: [PATCH 2/3] Clamp dialog dragging to primary window --- crates/bevy_ui_widgets/src/dialog.rs | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/bevy_ui_widgets/src/dialog.rs b/crates/bevy_ui_widgets/src/dialog.rs index 310d46ed6387d..1c014239ae3d2 100644 --- a/crates/bevy_ui_widgets/src/dialog.rs +++ b/crates/bevy_ui_widgets/src/dialog.rs @@ -12,15 +12,17 @@ use bevy_ecs::{ reflect::{ReflectComponent, ReflectEvent}, resource::Resource, schedule::{common_conditions::resource_changed, IntoScheduleConfigs}, - system::{Commands, Query, Res, ResMut}, + system::{Commands, Query, Res, ResMut, Single}, }; use bevy_input_focus::tab_navigation::TabGroup; +use bevy_math::Vec2; use bevy_picking::{ events::{Drag, DragStart, Pointer, Press}, pointer::PointerButton, }; use bevy_reflect::Reflect; use bevy_ui::{GlobalZIndex, UiScale, UiTransform, Val2}; +use bevy_window::{PrimaryWindow, Window}; use crate::ModalDialog; @@ -60,7 +62,9 @@ pub struct DialogDragHandle; #[reflect(Component)] pub struct DialogDragState { /// The owning dialog's `UiTransform.translation` - start: Val2, + start_dialog_translation: Val2, + /// The starting location of the drag (logical coordinates) + start_pointer_location: Vec2, } /// Open floating dialogs, ordered bottom-to-top; the front-most is last. @@ -141,6 +145,7 @@ fn dialog_drag_start( q_parent: Query<&ChildOf>, q_transform: Query<&UiTransform>, mut q_state: Query<&mut DialogDragState>, + ui_scale: Res, ) { if drag_start.button != PointerButton::Primary { return; @@ -156,10 +161,11 @@ fn dialog_drag_start( else { return; }; - state.start = q_transform + state.start_dialog_translation = q_transform .get(dialog) .map(|t| t.translation) .expect("Cannot get translation from dialog"); + state.start_pointer_location = drag_start.pointer_location.position / ui_scale.0; } /// Move a dialog by dragging its [`DialogDragHandle`], positioning it at the @@ -171,6 +177,7 @@ fn dialog_drag( q_parent: Query<&ChildOf>, mut q_transform: Query<&mut UiTransform>, ui_scale: Res, + primary_window: Single<&Window, With>, ) { if drag.button != PointerButton::Primary { return; @@ -185,13 +192,20 @@ fn dialog_drag( else { return; }; + + const SCREEN_EDGE_MARGIN: f32 = 16.; + // `distance` is in logical pixels; a `Val::Px` translation is scaled by - // `UiScale`, so divide that out. - let offset = drag.distance / ui_scale.0; + // `UiScale`, so divide that out. Then clamp to the screen region using + // the start of the pointer pos as an offset from screen bounds. + let clamped_offset = (drag.distance / ui_scale.0).clamp( + Vec2::splat(SCREEN_EDGE_MARGIN) - state.start_pointer_location, + primary_window.size() - Vec2::splat(SCREEN_EDGE_MARGIN) - state.start_pointer_location, + ); if let Ok(mut transform) = q_transform.get_mut(dialog) { transform.translation = state - .start - .try_add(Val2::px(offset.x, offset.y)) + .start_dialog_translation + .try_add(Val2::px(clamped_offset.x, clamped_offset.y)) .expect("Cannot add offset to dialog translation"); } } From 6c7f5a04ed1dbf115d8ec439d217d415ad5a629b Mon Sep 17 00:00:00 2001 From: John Payne <20407779+johngpayne@users.noreply.github.com> Date: Tue, 23 Jun 2026 01:40:26 +0100 Subject: [PATCH 3/3] doc fix --- crates/bevy_ui_widgets/src/dialog.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_ui_widgets/src/dialog.rs b/crates/bevy_ui_widgets/src/dialog.rs index 1c014239ae3d2..a491fdab5c6bc 100644 --- a/crates/bevy_ui_widgets/src/dialog.rs +++ b/crates/bevy_ui_widgets/src/dialog.rs @@ -27,7 +27,7 @@ use bevy_window::{PrimaryWindow, Window}; use crate::ModalDialog; /// A dialog box. When `ModalDialog` is also present it traps focus and is backed by a -/// [`ModalDialogBarrier`]; when false it is a movable, non-blocking floating window. +/// `ModalDialogBarrier`; when false it is a movable, non-blocking floating window. #[derive(Component, Debug, Reflect, Clone, Default)] #[require(AccessibilityNode(accesskit::Node::new(Role::Dialog)))] #[require(TabGroup { order: 0, modal: false })]