Skip to content

Commit 434f3e9

Browse files
Support floating dialogs in feathers and underlying bevy_ui_widgets. (#24705)
# Objective - Many editor requirements require floating dialogs which can be re-ordered and moved around, this would be useful for example for a settings window or entity debugger. ## Solution - I took the existing modal dialog as a starting point and expanded it at the feathers level AND at the underlying bevy_ui_widgets level. The naming was a bit of a clash but on advice I've got for no change to existing stuff: - At `bevy_ui_widgets` level there was `modal.rs` containing `ModalDialog`, I've added `dialog.rs` containing `Dialog` and also moved `RequestClose` from modal to dialog. - At `bevy_feathers` level there was `dialog.rs` containing `FeathersDialog` (a modal dialog), I've added new `FeathersFloatingDialog` (a non-modal dialog) ## Testing - Tested locally with a simple app <img width="260" height="437" alt="image" src="https://github.com/user-attachments/assets/0f5e098a-37c7-44e3-8b43-50dcd14bd50a" /> --------- Co-authored-by: John Payne <20407779+johngpayne@users.noreply.github.com> Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com>
1 parent cffef5b commit 434f3e9

4 files changed

Lines changed: 358 additions & 24 deletions

File tree

crates/bevy_feathers/src/controls/dialog.rs

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,19 @@ use bevy_reflect::{prelude::ReflectDefault, Reflect};
77
use bevy_scene::{bsn, bsn_list, on, Scene, SceneComponent, SceneList};
88
use bevy_text::FontWeight;
99
use bevy_ui::{
10-
px, vh, vw, AlignItems, BorderRadius, BoxShadow, Display, FixedNode, FlexDirection,
11-
GlobalZIndex, JustifyContent, Node, OverrideClip, PositionType, UiRect, Val,
10+
px, vh, vw, widget::Text, AlignItems, BorderRadius, BoxShadow, Display, FixedNode,
11+
FlexDirection, GlobalZIndex, JustifyContent, Node, OverrideClip, PositionType, UiRect, Val,
12+
};
13+
use bevy_ui_widgets::{
14+
Activate, Dialog, DialogDragHandle, ModalDialog, ModalDialogBarrier, RequestClose,
1215
};
13-
use bevy_ui_widgets::{Activate, ModalDialog, ModalDialogBarrier, RequestClose};
1416

1517
use crate::{
1618
constants::{fonts, icons, size},
1719
controls::{ButtonVariant, FeathersToolButton},
1820
display::icon,
1921
font_styles::InheritableFont,
20-
theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor},
22+
theme::{InheritableThemeTextColor, ThemeBackgroundColor, ThemeBorderColor, ThemedText},
2123
tokens,
2224
};
2325

@@ -93,6 +95,101 @@ impl FeathersDialog {
9395
}
9496
}
9597

98+
/// Props used to construct a [`FeathersFloatingDialog`] scene.
99+
pub struct FeathersFloatingDialogProps {
100+
/// Title shown in the window's drag bar.
101+
pub title: String,
102+
/// Body content of the window.
103+
pub contents: Box<dyn SceneList>,
104+
/// How wide the window should be.
105+
pub width: Val,
106+
/// Initial left offset (the window is absolutely positioned).
107+
pub left: Val,
108+
/// Initial top offset.
109+
pub top: Val,
110+
}
111+
112+
impl Default for FeathersFloatingDialogProps {
113+
fn default() -> Self {
114+
Self {
115+
title: String::new(),
116+
contents: Box::new(bsn_list!()),
117+
width: Val::Auto,
118+
left: px(120),
119+
top: px(120),
120+
}
121+
}
122+
}
123+
124+
/// A non-modal, movable floating dialog with a draggable title bar and a close button.
125+
#[derive(SceneComponent, Default, Clone, Reflect)]
126+
#[scene(FeathersFloatingDialogProps)]
127+
#[reflect(Component, Clone, Default)]
128+
pub struct FeathersFloatingDialog;
129+
130+
impl FeathersFloatingDialog {
131+
/// Scene function for a floating window.
132+
pub fn scene(props: FeathersFloatingDialogProps) -> impl Scene {
133+
bsn! {
134+
Node {
135+
display: Display::Flex,
136+
flex_direction: FlexDirection::Column,
137+
align_items: AlignItems::Stretch,
138+
position_type: PositionType::Absolute,
139+
left: {props.left},
140+
top: {props.top},
141+
border_radius: BorderRadius::all(px(4)),
142+
border: UiRect::all(px(1.0)),
143+
width: {props.width},
144+
}
145+
Dialog
146+
ThemeBackgroundColor(tokens::DIALOG_BG)
147+
ThemeBorderColor(tokens::DIALOG_BORDER)
148+
InheritableThemeTextColor(tokens::DIALOG_TEXT)
149+
BoxShadow::new(
150+
Srgba::BLACK.with_alpha(0.9).into(),
151+
px(0),
152+
px(0),
153+
px(1),
154+
px(4),
155+
)
156+
// Closing despawns the window.
157+
on(|close: On<RequestClose>, mut commands: Commands| {
158+
commands.entity(close.event_target()).despawn();
159+
})
160+
Children [
161+
// Title bar; dragging it moves the window.
162+
(
163+
Node {
164+
display: Display::Flex,
165+
flex_direction: FlexDirection::Row,
166+
align_items: AlignItems::Center,
167+
justify_content: JustifyContent::SpaceBetween,
168+
padding: UiRect::all(px(6.0)),
169+
}
170+
DialogDragHandle
171+
ThemeBackgroundColor(tokens::DIALOG_HEADER_BG)
172+
InheritableFont {
173+
font: fonts::REGULAR,
174+
font_size: size::HEADER_FONT,
175+
weight: FontWeight::BOLD,
176+
}
177+
Children [
178+
(Text({props.title}) ThemedText),
179+
@FeathersDialogClose
180+
]
181+
),
182+
(
183+
@FeathersDialogBody
184+
Children [
185+
{props.contents}
186+
]
187+
)
188+
]
189+
}
190+
}
191+
}
192+
96193
/// Header section for a modal dialog
97194
#[derive(SceneComponent, Default, Clone, Reflect)]
98195
#[reflect(Component, Clone, Default)]
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
use accesskit::Role;
2+
use bevy_a11y::AccessibilityNode;
3+
use bevy_app::{App, Plugin, Update};
4+
use bevy_ecs::{
5+
component::Component,
6+
entity::Entity,
7+
event::EntityEvent,
8+
hierarchy::ChildOf,
9+
lifecycle::{Add, Remove},
10+
observer::On,
11+
query::{With, Without},
12+
reflect::{ReflectComponent, ReflectEvent},
13+
resource::Resource,
14+
schedule::{common_conditions::resource_changed, IntoScheduleConfigs},
15+
system::{Commands, Query, Res, ResMut, Single},
16+
};
17+
use bevy_input_focus::tab_navigation::TabGroup;
18+
use bevy_log::warn;
19+
use bevy_math::Vec2;
20+
use bevy_picking::{
21+
events::{Drag, DragStart, Pointer, Press},
22+
pointer::PointerButton,
23+
};
24+
use bevy_reflect::Reflect;
25+
use bevy_ui::{GlobalZIndex, UiScale, UiTransform, Val2};
26+
use bevy_window::{PrimaryWindow, Window};
27+
28+
use crate::ModalDialog;
29+
30+
/// A dialog box. When [`ModalDialog`] is also present it traps focus and is backed by a
31+
/// [`crate::ModalDialogBarrier`]; when absent it is a movable, non-blocking floating window.
32+
#[derive(Component, Debug, Reflect, Clone, Default)]
33+
#[require(AccessibilityNode(accesskit::Node::new(Role::Dialog)))]
34+
#[require(TabGroup { order: 0, modal: false })]
35+
#[reflect(Component)]
36+
pub struct Dialog;
37+
38+
/// Event used to indicate that the dialog wants to be closed. This can happen because
39+
/// the user clicked on the barrier, hit the escape key, or clicked the close box in the dialog
40+
/// title. This event propagates so that the owner of the dialog can despawn it.
41+
#[derive(EntityEvent, Clone, Debug)]
42+
#[entity_event(propagate, auto_propagate)]
43+
#[derive(Reflect)]
44+
#[reflect(Event)]
45+
pub struct RequestClose {
46+
/// The [`Dialog`] that triggered this event.
47+
#[event_target]
48+
pub source: Entity,
49+
}
50+
51+
const FLOATING_Z_BASE: i32 = 50;
52+
const FLOATING_Z_MAX: i32 = 89;
53+
54+
/// Marks a region (e.g. a title bar) that drags its owning [`Dialog`].
55+
#[derive(Component, Debug, Reflect, Clone, Default)]
56+
#[require(DialogDragState)]
57+
#[reflect(Component)]
58+
pub struct DialogDragHandle;
59+
60+
/// Records the owning dialog's translation when a drag begins, so each drag event
61+
/// can set an absolute position from the drag's cumulative distance.
62+
#[derive(Component, Debug, Reflect, Clone, Default)]
63+
#[reflect(Component)]
64+
pub struct DialogDragState {
65+
/// The owning dialog's `UiTransform.translation`
66+
start_dialog_translation: Val2,
67+
/// The starting location of the drag (logical coordinates)
68+
start_pointer_location: Vec2,
69+
}
70+
71+
/// Open floating dialogs, ordered bottom-to-top; the front-most is last.
72+
#[derive(Resource, Default)]
73+
pub struct DialogStack(Vec<Entity>);
74+
75+
impl DialogStack {
76+
/// Add (or move) a dialog to the front of the stack.
77+
fn push_top(&mut self, entity: Entity) {
78+
self.0.retain(|&e| e != entity);
79+
self.0.push(entity);
80+
}
81+
82+
/// Remove a dialog from the stack.
83+
fn remove(&mut self, entity: Entity) {
84+
self.0.retain(|&e| e != entity);
85+
}
86+
}
87+
88+
/// Track newly-spawned floating dialogs at the top of the stack.
89+
fn register_dialog(
90+
add: On<Add, Dialog>,
91+
q_dialog: Query<&Dialog, Without<ModalDialog>>,
92+
mut stack: ResMut<DialogStack>,
93+
) {
94+
let entity = add.event_target();
95+
if q_dialog.contains(entity) {
96+
stack.push_top(entity);
97+
}
98+
}
99+
100+
/// Drop dialogs from the stack when they despawn.
101+
fn deregister_dialog(
102+
remove: On<Remove, Dialog>,
103+
q_dialog: Query<&Dialog, Without<ModalDialog>>,
104+
mut stack: ResMut<DialogStack>,
105+
) {
106+
let entity = remove.event_target();
107+
if q_dialog.contains(entity) {
108+
stack.remove(entity);
109+
}
110+
}
111+
112+
/// Give each floating dialog a `GlobalZIndex` from its stack position, so order
113+
/// drives both draw order and pointer-pick order.
114+
fn sync_dialog_z(stack: Res<DialogStack>, mut commands: Commands) {
115+
for (index, &entity) in stack.0.iter().enumerate() {
116+
let z = (FLOATING_Z_BASE + index as i32).min(FLOATING_Z_MAX);
117+
commands.entity(entity).insert(GlobalZIndex(z));
118+
}
119+
}
120+
121+
/// Raise a floating dialog to the front of the stack when it is pressed.
122+
fn bring_to_front(
123+
press: On<Pointer<Press>>,
124+
q_dialog: Query<&Dialog, Without<ModalDialog>>,
125+
q_parent: Query<&ChildOf>,
126+
mut stack: ResMut<DialogStack>,
127+
) {
128+
let target = press.event_target();
129+
let Some(dialog) = core::iter::once(target)
130+
.chain(q_parent.iter_ancestors(target))
131+
.find(|&e| q_dialog.contains(e))
132+
else {
133+
return;
134+
};
135+
// Already on top.
136+
if stack.0.last() == Some(&dialog) {
137+
return;
138+
}
139+
stack.push_top(dialog);
140+
}
141+
142+
/// Record the dialog's translation when a drag begins on its [`DialogDragHandle`].
143+
fn dialog_drag_start(
144+
drag_start: On<Pointer<DragStart>>,
145+
q_dialog: Query<(), With<Dialog>>,
146+
q_parent: Query<&ChildOf>,
147+
q_transform: Query<&UiTransform>,
148+
mut q_state: Query<&mut DialogDragState>,
149+
ui_scale: Res<UiScale>,
150+
) {
151+
if drag_start.button != PointerButton::Primary {
152+
return;
153+
}
154+
// Only the handle entity itself drives the move.
155+
let handle = drag_start.event_target();
156+
let Ok(mut state) = q_state.get_mut(handle) else {
157+
return;
158+
};
159+
let Some(dialog) = q_parent
160+
.iter_ancestors(handle)
161+
.find(|&e| q_dialog.contains(e))
162+
else {
163+
return;
164+
};
165+
166+
if let Ok(translation) = q_transform.get(dialog).map(|t| t.translation) {
167+
state.start_dialog_translation = translation;
168+
} else {
169+
warn!("Cannot get translation from dialog for dragging");
170+
state.start_dialog_translation = Val2::ZERO;
171+
}
172+
state.start_pointer_location = drag_start.pointer_location.position / ui_scale.0;
173+
}
174+
175+
/// Move a dialog by dragging its [`DialogDragHandle`], positioning it at the
176+
/// drag-start translation plus the drag's cumulative distance.
177+
fn dialog_drag(
178+
drag: On<Pointer<Drag>>,
179+
q_handle: Query<&DialogDragState, With<DialogDragHandle>>,
180+
q_dialog: Query<(), With<Dialog>>,
181+
q_parent: Query<&ChildOf>,
182+
mut q_transform: Query<&mut UiTransform>,
183+
ui_scale: Res<UiScale>,
184+
// TODO: multiple windows? dragging between them, etc
185+
primary_window: Single<&Window, With<PrimaryWindow>>,
186+
) {
187+
if drag.button != PointerButton::Primary {
188+
return;
189+
}
190+
let handle = drag.event_target();
191+
let Ok(state) = q_handle.get(handle) else {
192+
return;
193+
};
194+
let Some(dialog) = q_parent
195+
.iter_ancestors(handle)
196+
.find(|&e| q_dialog.contains(e))
197+
else {
198+
return;
199+
};
200+
201+
const SCREEN_EDGE_MARGIN: f32 = 16.;
202+
203+
// `distance` is in logical pixels; a `Val::Px` translation is scaled by
204+
// `UiScale`, so divide that out. Then clamp to the screen region using
205+
// the start of the pointer pos as an offset from screen bounds.
206+
let clamped_offset = (drag.distance / ui_scale.0).clamp(
207+
Vec2::splat(SCREEN_EDGE_MARGIN) - state.start_pointer_location,
208+
primary_window.size() - Vec2::splat(SCREEN_EDGE_MARGIN) - state.start_pointer_location,
209+
);
210+
if let Ok(mut transform) = q_transform.get_mut(dialog)
211+
&& let Ok(translation) = state
212+
.start_dialog_translation
213+
.try_add(Val2::px(clamped_offset.x, clamped_offset.y))
214+
{
215+
transform.translation = translation;
216+
}
217+
}
218+
219+
/// Plugin that adds the observers and systems for the [`Dialog`] widget.
220+
pub struct DialogPlugin;
221+
222+
impl Plugin for DialogPlugin {
223+
fn build(&self, app: &mut App) {
224+
app.init_resource::<DialogStack>()
225+
.add_observer(register_dialog)
226+
.add_observer(deregister_dialog)
227+
.add_observer(dialog_drag_start)
228+
.add_observer(dialog_drag)
229+
.add_observer(bring_to_front)
230+
.add_systems(
231+
Update,
232+
sync_dialog_z.run_if(resource_changed::<DialogStack>),
233+
);
234+
}
235+
}

crates/bevy_ui_widgets/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
2929
mod button;
3030
mod checkbox;
31+
mod dialog;
3132
mod list;
3233
mod menu;
3334
mod modal;
@@ -41,6 +42,7 @@ mod text_input;
4142

4243
pub use button::*;
4344
pub use checkbox::*;
45+
pub use dialog::*;
4446
pub use list::*;
4547
pub use menu::*;
4648
pub use modal::*;
@@ -70,6 +72,7 @@ impl PluginGroup for UiWidgetsPlugins {
7072
.add(EditableTextInputPlugin)
7173
.add(ListBoxPlugin)
7274
.add(MenuPlugin)
75+
.add(DialogPlugin)
7376
.add(ModalDialogPlugin)
7477
.add(PopoverPlugin)
7578
.add(RadioGroupPlugin)

0 commit comments

Comments
 (0)