bevy_sprinkles_editor 0.2.0

GPU particle system editor for Bevy
use bevy::input::mouse::MouseMotion;
use bevy::picking::hover::Hovered;
use bevy::prelude::*;
use bevy::ui::UiGlobalTransform;
use bevy::window::SystemCursorIcon;

use crate::ui::tokens::{BACKGROUND_COLOR, BORDER_COLOR};
use crate::ui::widgets::cursor::{ActiveCursor, HoverCursor};

const RESIZE_HANDLE_WIDTH: u32 = 12;

pub fn plugin(app: &mut App) {
    app.add_systems(
        Update,
        (
            spawn_resize_handles,
            sync_resize_handle_positions,
            handle_resize_drag,
        ),
    );
}

#[derive(Component)]
pub struct EditorPanel;

#[derive(Component, Default, Clone, Copy, PartialEq, Eq)]
pub enum PanelDirection {
    #[default]
    Left,
}

#[derive(Component)]
pub struct PanelWidth {
    pub current: u32,
    pub min: u32,
    pub max: u32,
}

#[derive(Component)]
pub struct PanelResizeHandle {
    pub panel: Entity,
    pub direction: PanelDirection,
}

#[derive(Component, Default)]
pub struct ResizeDragState {
    pub dragging: bool,
    pub accumulated_delta: f32,
}

pub struct PanelProps {
    pub direction: PanelDirection,
    pub width: u32,
    pub min_width: u32,
    pub max_width: u32,
}

impl Default for PanelProps {
    fn default() -> Self {
        Self {
            direction: PanelDirection::default(),
            width: 250,
            min_width: 100,
            max_width: 500,
        }
    }
}

impl PanelProps {
    pub fn new(direction: PanelDirection) -> Self {
        Self {
            direction,
            ..default()
        }
    }

    pub fn with_width(mut self, width: u32) -> Self {
        self.width = width;
        self
    }

    pub fn with_min_width(mut self, min_width: u32) -> Self {
        self.min_width = min_width;
        self
    }

    pub fn with_max_width(mut self, max_width: u32) -> Self {
        self.max_width = max_width;
        self
    }
}

pub fn panel(props: PanelProps) -> impl Bundle {
    let PanelProps {
        direction,
        width,
        min_width,
        max_width,
    } = props;

    let border = match direction {
        PanelDirection::Left => UiRect::right(px(1)),
    };
    let margin = match direction {
        PanelDirection::Left => UiRect::ZERO,
    };

    (
        EditorPanel,
        direction,
        PanelWidth {
            current: width,
            min: min_width,
            max: max_width,
        },
        Hovered::default(),
        Node {
            width: px(width),
            height: percent(100),
            min_height: px(0.0),
            flex_direction: FlexDirection::Column,
            border,
            margin,
            position_type: PositionType::Relative,
            overflow: Overflow::scroll_y(),
            ..default()
        },
        BackgroundColor(BACKGROUND_COLOR.into()),
        BorderColor::all(BORDER_COLOR),
    )
}

fn spawn_resize_handles(
    mut commands: Commands,
    panels: Query<(Entity, &PanelDirection, &ChildOf), Added<EditorPanel>>,
) {
    for (panel_entity, &direction, child_of) in &panels {
        let handle = commands
            .spawn((
                PanelResizeHandle {
                    panel: panel_entity,
                    direction,
                },
                ResizeDragState::default(),
                Hovered::default(),
                HoverCursor(SystemCursorIcon::ColResize),
                Node {
                    position_type: PositionType::Absolute,
                    width: px(RESIZE_HANDLE_WIDTH),
                    ..default()
                },
                ZIndex(10),
                Pickable {
                    should_block_lower: true,
                    is_hoverable: true,
                },
            ))
            .id();
        commands.entity(child_of.parent()).add_child(handle);
    }
}

fn sync_resize_handle_positions(
    panels: Query<(&PanelDirection, &UiGlobalTransform, &ComputedNode), With<EditorPanel>>,
    parents: Query<(&UiGlobalTransform, &ComputedNode), Without<EditorPanel>>,
    mut handles: Query<(&PanelResizeHandle, &ChildOf, &mut Node)>,
) {
    let half = (RESIZE_HANDLE_WIDTH / 2) as f32;

    for (handle, child_of, mut node) in &mut handles {
        let Ok((direction, panel_transform, panel_computed)) = panels.get(handle.panel) else {
            continue;
        };
        let Ok((parent_transform, parent_computed)) = parents.get(child_of.parent()) else {
            continue;
        };

        let scale = panel_computed.inverse_scale_factor();
        let panel_center = panel_transform.translation.x * scale;
        let parent_center = parent_transform.translation.x * scale;
        let panel_half_w = panel_computed.size().x * scale / 2.0;
        let parent_half_w = parent_computed.size().x * scale / 2.0;

        let parent_left = parent_center - parent_half_w;

        let panel_edge = match direction {
            PanelDirection::Left => panel_center + panel_half_w,
        };

        node.left = px(panel_edge - parent_left - half);
        node.top = px(0.0);
        node.height = percent(100);
    }
}

fn handle_resize_drag(
    mut commands: Commands,
    mut handles: Query<(Entity, &PanelResizeHandle, &mut ResizeDragState, &Hovered)>,
    mut panels: Query<(&mut Node, &mut PanelWidth), With<EditorPanel>>,
    mouse: Res<ButtonInput<MouseButton>>,
    mut mouse_motion: MessageReader<MouseMotion>,
) {
    let cursor_delta: f32 = mouse_motion.read().map(|e| e.delta.x).sum();

    for (entity, handle, mut drag_state, hovered) in &mut handles {
        if mouse.just_pressed(MouseButton::Left) && hovered.get() {
            drag_state.dragging = true;
            drag_state.accumulated_delta = 0.0;
            commands
                .entity(entity)
                .insert(ActiveCursor(SystemCursorIcon::ColResize));
        }

        if mouse.just_released(MouseButton::Left) {
            drag_state.dragging = false;
            commands.entity(entity).remove::<ActiveCursor>();
        }

        if drag_state.dragging && cursor_delta != 0.0 {
            if let Ok((mut node, mut panel_width)) = panels.get_mut(handle.panel) {
                let delta = match handle.direction {
                    PanelDirection::Left => cursor_delta,
                };

                drag_state.accumulated_delta += delta;
                let new_width = ((panel_width.current as f32) + drag_state.accumulated_delta)
                    .clamp(panel_width.min as f32, panel_width.max as f32)
                    as u32;

                if new_width != panel_width.current {
                    drag_state.accumulated_delta = 0.0;
                    panel_width.current = new_width;
                    node.width = px(new_width);
                }
            }
        }
    }
}