haalka/
viewport_mutable.rs

1//! Semantics for managing elements whose contents can be partially visible, see
2//! [`ViewportMutable`].
3
4use std::{
5    collections::HashSet,
6    sync::{Arc, OnceLock},
7};
8
9use super::{
10    raw::{RawElWrapper, observe, register_system, utils::remove_system_holder_on_remove},
11    utils::clone,
12};
13use apply::Apply;
14use bevy_app::prelude::*;
15use bevy_ecs::{prelude::*, system::SystemParam};
16use bevy_math::prelude::*;
17use bevy_transform::prelude::*;
18use bevy_ui::prelude::*;
19use futures_signals::signal::{Mutable, Signal};
20
21/// Dimensions of an element's "scene", which contains both its visible (via its [`Viewport`]) and
22/// hidden parts.
23#[derive(Clone, Copy, Default, Debug)]
24pub struct Scene {
25    #[allow(missing_docs)]
26    pub width: f32,
27    #[allow(missing_docs)]
28    pub height: f32,
29}
30
31/// Data specifying the visible portion of an element's [`Scene`].
32#[derive(Clone, Copy, Default, Debug)]
33pub struct Viewport {
34    /// Horizontal offset.
35    pub offset_x: f32,
36    /// Vertical offset.
37    pub offset_y: f32,
38    #[allow(missing_docs)]
39    pub width: f32,
40    #[allow(missing_docs)]
41    pub height: f32,
42}
43
44// TODO: should not fire when scrolling doesn't actually change the viewport
45/// [`Component`] for holding the [`Scene`] and [`Viewport`]. Also an [`Event`] which is
46/// [`Trigger`]ed when the [`Viewport`] or [`Scene`] of a [`MutableViewport`] changes; only entities
47/// with the [`OnViewportLocationChange`] component receive this event.
48#[derive(Component, Event, Default)]
49pub struct MutableViewport {
50    #[allow(missing_docs)]
51    pub scene: Scene,
52    #[allow(missing_docs)]
53    pub viewport: Viewport,
54}
55
56/// [`MutableViewport`]s with this [`Component`] receive [`MutableViewport`] events.
57#[derive(Component)]
58pub struct OnViewportLocationChange;
59
60/// Along which axes the [`Viewport`] can be mutated.
61pub enum Axis {
62    #[allow(missing_docs)]
63    Horizontal,
64    #[allow(missing_docs)]
65    Vertical,
66    #[allow(missing_docs)]
67    Both,
68}
69
70/// Sentinel component to store the last scroll position set by a signal.
71/// This is used to break feedback loops in two-way bindings.
72#[derive(Component, Default, Debug)]
73struct LastSignalScrollPosition {
74    x: f32,
75    y: f32,
76}
77
78/// Enables the management of a limited visible window (viewport) onto the body of an element.
79/// CRITICALLY NOTE that methods expecting viewport mutability will not function without calling
80/// [`.mutable_viewport(...)`](ViewportMutable::mutable_viewport).
81pub trait ViewportMutable: RawElWrapper {
82    /// CRITICALLY NOTE, methods expecting viewport mutability will not function without calling
83    /// this method. I could not find a way to enforce this at compile time; please let me know if
84    /// you can.
85    fn mutable_viewport(self, axis: Axis) -> Self {
86        self.update_raw_el(move |raw_el| {
87            raw_el
88                .insert(MutableViewport::default())
89                .with_component::<Node>(move |mut node| {
90                    node.overflow = match axis {
91                        Axis::Horizontal => Overflow::scroll_x(),
92                        Axis::Vertical => Overflow::scroll_y(),
93                        Axis::Both => Overflow::scroll(),
94                    }
95                })
96        })
97    }
98
99    /// When this element's [`Scene`] or [`Viewport`] changes, run a [`System`] which takes
100    /// [`In`](`System::In`) this element's [`Entity`], [`Scene`], and [`Viewport`]. This method
101    /// can be called repeatedly to register many such handlers.
102    fn on_viewport_location_change_with_system<Marker>(
103        self,
104        handler: impl IntoSystem<In<(Entity, (Scene, Viewport))>, (), Marker> + Send + 'static,
105    ) -> Self {
106        self.update_raw_el(|raw_el| {
107            let system_holder = Arc::new(OnceLock::new());
108            raw_el
109            .insert(OnViewportLocationChange)
110            .on_spawn(clone!((system_holder) move |world, entity| {
111                let system = register_system(world, handler);
112                let _ = system_holder.set(system);
113                observe(world, entity, move |viewport_location_change: Trigger<MutableViewport>, mut commands: Commands| {
114                    let &MutableViewport { scene, viewport } = viewport_location_change.event();
115                    commands.run_system_with(system, (entity, (scene, viewport)));
116                });
117            }))
118            .apply(remove_system_holder_on_remove(system_holder))
119        })
120    }
121
122    /// When this element's [`Scene`] or [`Viewport`] changes, run a function with its [`Scene`] and
123    /// [`Viewport`]. This method can be called repeatedly to register many such handlers.
124    fn on_viewport_location_change(self, mut handler: impl FnMut(Scene, Viewport) + Send + Sync + 'static) -> Self {
125        self.on_viewport_location_change_with_system(move |In((_, (scene, viewport)))| handler(scene, viewport))
126    }
127
128    /// Reactively set the horizontal position of the viewport.
129    fn viewport_x_signal<S: Signal<Item = f32> + Send + 'static>(
130        mut self,
131        x_signal_option: impl Into<Option<S>>,
132    ) -> Self {
133        if let Some(x_signal) = x_signal_option.into() {
134            self = self.update_raw_el(|raw_el| {
135                raw_el
136                    .insert(LastSignalScrollPosition::default())
137                    .on_signal_with_system(
138                    x_signal,
139                    |In((entity, x)): In<(Entity, f32)>,
140                     mut query: Query<(&mut ScrollPosition, &mut LastSignalScrollPosition)>| {
141                        if let Ok((mut scroll_pos, mut last_signal_pos)) = query.get_mut(entity)
142                            && last_signal_pos.x.to_bits() != x.to_bits()
143                        {
144                            last_signal_pos.x = x;
145                            scroll_pos.offset_x = x;
146                        }
147                    },
148                )
149            });
150        }
151        self
152    }
153
154    /// Reactively set the vertical position of the viewport.
155    fn viewport_y_signal<S: Signal<Item = f32> + Send + 'static>(
156        mut self,
157        y_signal_option: impl Into<Option<S>>,
158    ) -> Self {
159        if let Some(y_signal) = y_signal_option.into() {
160            self = self.update_raw_el(|raw_el| {
161                raw_el
162                    .insert(LastSignalScrollPosition::default())
163                    .on_signal_with_system(
164                    y_signal,
165                    |In((entity, y)): In<(Entity, f32)>,
166                     mut query: Query<(&mut ScrollPosition, &mut LastSignalScrollPosition)>| {
167                        if let Ok((mut scroll_pos, mut last_signal_pos)) = query.get_mut(entity)
168                            && last_signal_pos.y.to_bits() != y.to_bits()
169                        {
170                            last_signal_pos.y = y;
171                            scroll_pos.offset_y = y;
172                        }
173                    },
174                )
175            });
176        }
177        self
178    }
179
180    /// Sync a [`Mutable<f32>`] with this element's viewport's x offset.
181    fn viewport_x_sync(self, viewport_x: Mutable<f32>) -> Self {
182        self.on_viewport_location_change_with_system(
183            move |In((entity, (_, viewport))): In<(Entity, (Scene, Viewport))>,
184                  last_signal_positions: Query<&LastSignalScrollPosition>| {
185                if let Ok(last_signal_pos) = last_signal_positions.get(entity)
186                    && last_signal_pos.x.to_bits() != viewport.offset_x.to_bits()
187                {
188                    viewport_x.set_neq(viewport.offset_x);
189                }
190            },
191        )
192    }
193
194    /// Sync a [`Mutable<f32>`] with this element's viewport's y offset.
195    fn viewport_y_sync(self, viewport_y: Mutable<f32>) -> Self {
196        self.on_viewport_location_change_with_system(
197            move |In((entity, (_, viewport))): In<(Entity, (Scene, Viewport))>,
198                  last_signal_positions: Query<&LastSignalScrollPosition>| {
199                if let Ok(last_signal_pos) = last_signal_positions.get(entity)
200                    && last_signal_pos.y.to_bits() != viewport.offset_y.to_bits()
201                {
202                    viewport_y.set_neq(viewport.offset_y);
203                }
204            },
205        )
206    }
207}
208
209/// Use to fetch the logical pixel coordinates of the UI node, based on its [`GlobalTransform`].
210#[derive(SystemParam)]
211pub struct LogicalRect<'w, 's> {
212    data: Query<'w, 's, (&'static ComputedNode, &'static GlobalTransform)>,
213}
214
215impl LogicalRect<'_, '_> {
216    /// Get the logical pixel coordinates of the UI node, based on its [`GlobalTransform`].
217    pub fn get(&self, entity: Entity) -> Option<Rect> {
218        if let Ok((computed_node, global_transform)) = self.data.get(entity) {
219            return Rect::from_center_size(global_transform.translation().xy(), computed_node.size()).apply(Some);
220        }
221        None
222    }
223}
224
225#[derive(SystemParam)]
226struct SceneViewport<'w, 's> {
227    childrens: Query<'w, 's, &'static Children>,
228    logical_rect: LogicalRect<'w, 's>,
229    scroll_positions: Query<'w, 's, &'static ScrollPosition>,
230}
231
232impl SceneViewport<'_, '_> {
233    fn get(&self, entity: Entity) -> Option<(Scene, Viewport)> {
234        if let Some(Vec2 {
235            x: viewport_width,
236            y: viewport_height,
237        }) = self.logical_rect.get(entity).as_ref().map(Rect::size)
238            && let Ok(&ScrollPosition { offset_x, offset_y }) = self.scroll_positions.get(entity)
239        {
240            let mut min = Vec2::MAX;
241            let mut max = Vec2::MIN;
242            for child in self
243                .childrens
244                .get(entity)
245                .ok()
246                .into_iter()
247                .flat_map(|children| children.iter())
248            {
249                if let Some(child_rect) = self.logical_rect.get(child) {
250                    min = min.min(child_rect.min);
251                    max = max.max(child_rect.max);
252                }
253            }
254            let scene = Scene {
255                width: max.x - min.x,
256                height: max.y - min.y,
257            };
258            let viewport = Viewport {
259                offset_x,
260                offset_y,
261                width: viewport_width,
262                height: viewport_height,
263            };
264            return Some((scene, viewport));
265        }
266        None
267    }
268}
269
270fn dispatch_viewport_location_change(
271    entity: Entity,
272    scene_viewports: &SceneViewport,
273    commands: &mut Commands,
274    checked_viewport_listeners: &mut HashSet<Entity>,
275) {
276    if let Some((scene, viewport)) = scene_viewports.get(entity) {
277        if let Ok(mut entity) = commands.get_entity(entity) {
278            entity.insert(MutableViewport { scene, viewport });
279        }
280        commands.trigger_targets(MutableViewport { scene, viewport }, entity);
281        checked_viewport_listeners.insert(entity);
282    }
283}
284
285#[allow(clippy::type_complexity)]
286fn viewport_location_change_dispatcher(
287    viewports: Query<
288        Entity,
289        (
290            Or<(Changed<ComputedNode>, Changed<ScrollPosition>, Changed<Children>)>,
291            With<OnViewportLocationChange>,
292        ),
293    >,
294    changed_computed_nodes: Query<Entity, Changed<ComputedNode>>,
295    viewport_location_change_listeners: Query<Entity, With<OnViewportLocationChange>>,
296    child_ofs: Query<&ChildOf>,
297    scene_viewports: SceneViewport,
298    mut commands: Commands,
299) {
300    let mut checked_viewport_listeners = HashSet::new();
301    for entity in viewports.iter() {
302        dispatch_viewport_location_change(entity, &scene_viewports, &mut commands, &mut checked_viewport_listeners);
303    }
304    for entity in changed_computed_nodes.iter() {
305        if let Ok(&ChildOf(parent)) = child_ofs.get(entity)
306            && !checked_viewport_listeners.contains(&parent)
307            && viewport_location_change_listeners.contains(parent)
308        {
309            dispatch_viewport_location_change(parent, &scene_viewports, &mut commands, &mut checked_viewport_listeners);
310        }
311    }
312}
313
314pub(super) fn plugin(app: &mut App) {
315    app.add_systems(
316        Update,
317        viewport_location_change_dispatcher.run_if(any_with_component::<OnViewportLocationChange>),
318    );
319}