haalka 0.7.1

ergonomic reactive Bevy UI library powered by FRP signals
Documentation
//! Semantics for managing elements whose contents can be partially visible, see
//! [`ViewportMutable`].

use std::{
    collections::HashSet,
    sync::{Arc, OnceLock},
};

use super::{
    element::BuilderWrapper,
    utils::{clone, observe, register_system, remove_system_holder_on_despawn},
};
use apply::Apply;
use bevy_app::prelude::*;
use bevy_ecs::{prelude::*, system::SystemParam};
use bevy_math::prelude::*;
use bevy_ui::prelude::*;
use jonmo::signal::Signal;

/// Dimensions of an element's "scene", which contains both its visible (via its [`Viewport`]) and
/// hidden parts.
#[derive(Clone, Copy, Default, Debug)]
pub struct Scene {
    #[allow(missing_docs)]
    pub width: f32,
    #[allow(missing_docs)]
    pub height: f32,
}

/// Data specifying the visible portion of an element's [`Scene`].
#[derive(Clone, Copy, Default, Debug)]
pub struct Viewport {
    /// Horizontal offset.
    pub offset_x: f32,
    /// Vertical offset.
    pub offset_y: f32,
    #[allow(missing_docs)]
    pub width: f32,
    #[allow(missing_docs)]
    pub height: f32,
}

// TODO: should not fire when scrolling doesn't actually change the viewport
/// [`Component`] for holding the [`Scene`] and [`Viewport`].
#[derive(Component, Default)]
pub struct MutableViewport {
    #[allow(missing_docs)]
    pub scene: Scene,
    #[allow(missing_docs)]
    pub viewport: Viewport,
}

/// [`EntityEvent`] triggered when the [`Viewport`] or [`Scene`] of a [`MutableViewport`] changes;
/// only entities with the [`OnViewportLocationChange`] component receive this event.
#[derive(EntityEvent)]
pub struct MutableViewportEvent {
    /// The entity whose viewport changed.
    pub entity: Entity,
    /// The updated viewport data.
    pub mutable_viewport: MutableViewport,
}

/// [`MutableViewport`]s with this [`Component`] receive [`MutableViewportEvent`] events.
#[derive(Component)]
pub struct OnViewportLocationChange;

/// Sentinel component to store the last scroll position set by a signal.
/// This is used to break feedback loops in two-way bindings.
#[derive(Component, Default, Debug)]
struct LastSignalScrollPosition {
    x: f32,
    y: f32,
}

/// Enables the management of a limited visible window (viewport) onto the body of an element.
/// CRITICALLY NOTE that methods expecting viewport mutability will not function without calling
/// [`.mutable_viewport(...)`](ViewportMutable::mutable_viewport).
pub trait ViewportMutable: BuilderWrapper {
    /// CRITICALLY NOTE, methods expecting viewport mutability will not function without calling
    /// this method. I could not find a way to enforce this at compile time; please let me know if
    /// you can.
    fn mutable_viewport(self, overflow: Overflow) -> Self {
        self.with_builder(move |builder| {
            builder
                .insert(MutableViewport::default())
                .with_component::<Node>(move |mut node| {
                    node.overflow = overflow;
                })
        })
    }

    /// When this element's [`Scene`] or [`Viewport`] changes, run a [`System`] which takes
    /// [`In`](`System::In`) this element's [`Entity`], [`Scene`], and [`Viewport`]. This method
    /// can be called repeatedly to register many such handlers.
    fn on_viewport_location_change<Marker>(
        self,
        handler: impl IntoSystem<In<(Entity, (Scene, Viewport))>, (), Marker> + Send + Sync + 'static,
    ) -> Self {
        self.with_builder(|builder| {
            let system_holder = Arc::new(OnceLock::new());
            builder
            .insert(OnViewportLocationChange)
            .on_spawn(clone!((system_holder) move |world, entity| {
                let system = register_system(world, handler);
                let _ = system_holder.set(system);
                observe(world, entity, move |viewport_location_change: On<MutableViewportEvent>, mut commands: Commands| {
                    let &MutableViewportEvent { mutable_viewport: MutableViewport { scene, viewport }, .. } = viewport_location_change.event();
                    commands.run_system_with(system, (entity, (scene, viewport)));
                });
            }))
            .apply(remove_system_holder_on_despawn(system_holder))
        })
    }

    /// Reactively set the horizontal position of the viewport.
    fn viewport_x_signal<S: Signal<Item = f32> + Send + 'static>(
        mut self,
        x_signal_option: impl Into<Option<S>>,
    ) -> Self {
        if let Some(x_signal) = x_signal_option.into() {
            self = self.with_builder(|builder| {
                builder.insert(LastSignalScrollPosition::default()).on_signal(
                    x_signal,
                    |In((entity, x)): In<(Entity, f32)>,
                     mut query: Query<(
                        &mut ScrollPosition,
                        &mut LastSignalScrollPosition,
                        Option<&ComputedNode>,
                    )>| {
                        if let Ok((mut scroll_pos, mut last_signal_pos, maybe_node)) = query.get_mut(entity)
                            && last_signal_pos.x.to_bits() != x.to_bits()
                        {
                            let mut target = x;

                            target = if let Some(node) = maybe_node {
                                target.clamp(0.0, max_scroll_offset(node).x)
                            } else {
                                target.max(0.0)
                            };

                            last_signal_pos.x = target;
                            scroll_pos.x = target;
                        }
                    },
                )
            });
        }
        self
    }

    /// Reactively set the vertical position of the viewport.
    fn viewport_y_signal<S: Signal<Item = f32> + Send + 'static>(
        mut self,
        y_signal_option: impl Into<Option<S>>,
    ) -> Self {
        if let Some(y_signal) = y_signal_option.into() {
            self = self.with_builder(|builder| {
                builder.insert(LastSignalScrollPosition::default()).on_signal(
                    y_signal,
                    |In((entity, y)): In<(Entity, f32)>,
                     mut query: Query<(
                        &mut ScrollPosition,
                        &mut LastSignalScrollPosition,
                        Option<&ComputedNode>,
                    )>| {
                        if let Ok((mut scroll_pos, mut last_signal_pos, maybe_node)) = query.get_mut(entity)
                            && last_signal_pos.y.to_bits() != y.to_bits()
                        {
                            let mut target = y;

                            target = if let Some(node) = maybe_node {
                                target.clamp(0.0, max_scroll_offset(node).y)
                            } else {
                                target.max(0.0)
                            };

                            last_signal_pos.y = target;
                            scroll_pos.y = target;
                        }
                    },
                )
            });
        }
        self
    }
}

/// Use to fetch the logical pixel coordinates of the UI node, based on its [`UiGlobalTransform`].
#[derive(SystemParam)]
pub struct LogicalRect<'w, 's> {
    data: Query<'w, 's, (&'static ComputedNode, &'static UiGlobalTransform)>,
}

impl LogicalRect<'_, '_> {
    /// Get the logical pixel coordinates of the UI node, based on its [`UiGlobalTransform`].
    pub fn get(&self, entity: Entity) -> Option<Rect> {
        if let Ok((computed_node, global_transform)) = self.data.get(entity) {
            return Rect::from_center_size(global_transform.translation.xy(), computed_node.size()).apply(Some);
        }
        None
    }
}

#[derive(SystemParam)]
struct SceneViewport<'w, 's> {
    childrens: Query<'w, 's, &'static Children>,
    logical_rect: LogicalRect<'w, 's>,
    scroll_positions: Query<'w, 's, &'static ScrollPosition>,
}

impl SceneViewport<'_, '_> {
    fn get(&self, entity: Entity) -> Option<(Scene, Viewport)> {
        if let Some(Vec2 {
            x: viewport_width,
            y: viewport_height,
        }) = self.logical_rect.get(entity).as_ref().map(Rect::size)
            && let Ok(&ScrollPosition(Vec2 {
                x: offset_x,
                y: offset_y,
            })) = self.scroll_positions.get(entity)
        {
            let mut min = Vec2::MAX;
            let mut max = Vec2::MIN;
            for child in self
                .childrens
                .get(entity)
                .ok()
                .into_iter()
                .flat_map(|children| children.iter())
            {
                if let Some(child_rect) = self.logical_rect.get(child) {
                    min = min.min(child_rect.min);
                    max = max.max(child_rect.max);
                }
            }
            let scene = Scene {
                width: max.x - min.x,
                height: max.y - min.y,
            };
            let viewport = Viewport {
                offset_x,
                offset_y,
                width: viewport_width,
                height: viewport_height,
            };
            return Some((scene, viewport));
        }
        None
    }
}

fn dispatch_viewport_location_change(
    entity: Entity,
    scene_viewports: &SceneViewport,
    commands: &mut Commands,
    checked_viewport_listeners: &mut HashSet<Entity>,
) {
    if let Some((scene, viewport)) = scene_viewports.get(entity) {
        if let Ok(mut entity) = commands.get_entity(entity) {
            entity.insert(MutableViewport { scene, viewport });
        }
        commands.trigger(MutableViewportEvent {
            entity,
            mutable_viewport: MutableViewport { scene, viewport },
        });
        checked_viewport_listeners.insert(entity);
    }
}

#[allow(clippy::type_complexity)]
fn viewport_location_change_dispatcher(
    viewports: Query<
        Entity,
        (
            Or<(Changed<ComputedNode>, Changed<ScrollPosition>, Changed<Children>)>,
            With<OnViewportLocationChange>,
        ),
    >,
    changed_computed_nodes: Query<Entity, Changed<ComputedNode>>,
    viewport_location_change_listeners: Query<Entity, With<OnViewportLocationChange>>,
    child_ofs: Query<&ChildOf>,
    scene_viewports: SceneViewport,
    mut commands: Commands,
) {
    let mut checked_viewport_listeners = HashSet::new();
    for entity in viewports.iter() {
        dispatch_viewport_location_change(entity, &scene_viewports, &mut commands, &mut checked_viewport_listeners);
    }
    for entity in changed_computed_nodes.iter() {
        if let Ok(&ChildOf(parent)) = child_ofs.get(entity)
            && !checked_viewport_listeners.contains(&parent)
            && viewport_location_change_listeners.contains(parent)
        {
            dispatch_viewport_location_change(parent, &scene_viewports, &mut commands, &mut checked_viewport_listeners);
        }
    }
}

fn max_scroll_offset(node: &ComputedNode) -> Vec2 {
    (node.content_size - node.size() + node.scrollbar_size).max(Vec2::ZERO)
}

pub(super) fn plugin(app: &mut App) {
    app.add_systems(
        Update,
        viewport_location_change_dispatcher.run_if(any_with_component::<OnViewportLocationChange>),
    );
}