Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 101 additions & 4 deletions crates/bevy_feathers/src/controls/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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<dyn SceneList>,
/// 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<RequestClose>, 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)]
Expand Down
215 changes: 215 additions & 0 deletions crates/bevy_ui_widgets/src/dialog.rs
Original file line number Diff line number Diff line change
@@ -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<Entity>);

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<Add, Dialog>,
q_dialog: Query<&Dialog, Without<ModalDialog>>,
mut stack: ResMut<DialogStack>,
) {
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<Remove, Dialog>,
q_dialog: Query<&Dialog, Without<ModalDialog>>,
mut stack: ResMut<DialogStack>,
) {
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<DialogStack>, 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<Pointer<Press>>,
q_dialog: Query<&Dialog, Without<ModalDialog>>,
q_parent: Query<&ChildOf>,
mut stack: ResMut<DialogStack>,
) {
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<Pointer<DragStart>>,
q_dialog: Query<(), With<Dialog>>,
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<Pointer<Drag>>,
q_handle: Query<&DialogDragState, With<DialogDragHandle>>,
q_dialog: Query<(), With<Dialog>>,
q_parent: Query<&ChildOf>,
mut q_transform: Query<&mut UiTransform>,
ui_scale: Res<UiScale>,
) {
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::<DialogStack>()
.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::<DialogStack>),
);
}
}
3 changes: 3 additions & 0 deletions crates/bevy_ui_widgets/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@

mod button;
mod checkbox;
mod dialog;
mod list;
mod menu;
mod modal;
Expand All @@ -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::*;
Expand Down Expand Up @@ -70,6 +72,7 @@ impl PluginGroup for UiWidgetsPlugins {
.add(EditableTextInputPlugin)
.add(ListBoxPlugin)
.add(MenuPlugin)
.add(DialogPlugin)
.add(ModalDialogPlugin)
.add(PopoverPlugin)
.add(RadioGroupPlugin)
Expand Down
Loading
Loading