|
| 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 | +} |
0 commit comments