bevy_sprinkles_editor 0.1.3

GPU particle system editor for Bevy
use bevy::prelude::*;

use crate::ui::icons::{ICON_ADD, ICON_ARROW_DOWN};
use crate::ui::tokens::{BORDER_COLOR, FONT_PATH, TEXT_DISPLAY_COLOR, TEXT_SIZE};
use crate::ui::widgets::button::{ButtonClickEvent, ButtonVariant, IconButtonProps, icon_button};

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

#[derive(Component)]
pub struct EditorPanelSection;

#[derive(Component)]
struct PanelSectionHeader;

#[derive(Component)]
struct PanelSectionButtonsContainer;

#[derive(Component)]
pub struct PanelSectionAddButton(pub Entity);

#[derive(Component)]
struct PanelSectionCollapseButton(Entity);

#[derive(Component, Default)]
struct Collapsed(bool);

#[derive(Component)]
struct PanelSectionState {
    has_add_button: bool,
    collapsible: bool,
}

#[derive(Default, Clone, Copy)]
pub enum PanelSectionSize {
    #[default]
    MD,
    XL,
}

impl PanelSectionSize {
    fn padding(&self) -> UiRect {
        match self {
            Self::MD => UiRect::all(px(12)),
            Self::XL => UiRect::axes(px(24), px(14)),
        }
    }
}

#[derive(Default)]
pub struct PanelSectionProps {
    pub title: String,
    pub size: PanelSectionSize,
    pub has_add_button: bool,
    pub collapsible: bool,
}

impl PanelSectionProps {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            title: title.into(),
            ..default()
        }
    }

    pub fn with_size(mut self, size: PanelSectionSize) -> Self {
        self.size = size;
        self
    }

    pub fn with_add_button(mut self) -> Self {
        self.has_add_button = true;
        self
    }

    pub fn collapsible(mut self) -> Self {
        self.collapsible = true;
        self
    }
}

pub fn panel_section(props: PanelSectionProps, asset_server: &AssetServer) -> impl Bundle {
    let PanelSectionProps {
        title,
        size,
        has_add_button,
        collapsible,
    } = props;
    let font: Handle<Font> = asset_server.load(FONT_PATH);

    (
        EditorPanelSection,
        Collapsed::default(),
        Node {
            width: percent(100),
            flex_direction: FlexDirection::Column,
            row_gap: px(12),
            padding: size.padding(),
            border: UiRect::bottom(px(1)),
            ..default()
        },
        BorderColor::all(BORDER_COLOR),
        PanelSectionState {
            has_add_button,
            collapsible,
        },
        children![(
            PanelSectionHeader,
            Node {
                width: percent(100),
                justify_content: JustifyContent::SpaceBetween,
                align_items: AlignItems::Center,
                ..default()
            },
            children![
                (
                    Text::new(title),
                    TextFont {
                        font: font.into(),
                        font_size: TEXT_SIZE,
                        weight: FontWeight::SEMIBOLD,
                        ..default()
                    },
                    TextColor(TEXT_DISPLAY_COLOR.into()),
                ),
                (
                    PanelSectionButtonsContainer,
                    Node {
                        align_items: AlignItems::Center,
                        ..default()
                    },
                ),
            ],
        )],
    )
}

fn setup_panel_section_buttons(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    new_sections: Query<(Entity, &PanelSectionState, &Children), Added<EditorPanelSection>>,
    headers: Query<&Children, With<PanelSectionHeader>>,
    containers: Query<Entity, With<PanelSectionButtonsContainer>>,
) {
    for (section_entity, state, section_children) in &new_sections {
        let Some(&header_entity) = section_children.first() else {
            continue;
        };
        let Ok(header_children) = headers.get(header_entity) else {
            continue;
        };
        let Some(&container_entity) = header_children.get(1) else {
            continue;
        };
        if containers.get(container_entity).is_err() {
            continue;
        }

        if state.has_add_button {
            let add_entity = commands
                .spawn((
                    PanelSectionAddButton(section_entity),
                    icon_button(
                        IconButtonProps::new(ICON_ADD).variant(ButtonVariant::Ghost),
                        &asset_server,
                    ),
                ))
                .observe(on_add_click)
                .id();
            commands.entity(container_entity).add_child(add_entity);
        }

        if state.collapsible {
            let collapse_entity = commands
                .spawn((
                    PanelSectionCollapseButton(section_entity),
                    UiTransform {
                        rotation: Rot2::degrees(180.0),
                        ..default()
                    },
                    icon_button(
                        IconButtonProps::new(ICON_ARROW_DOWN).variant(ButtonVariant::Ghost),
                        &asset_server,
                    ),
                ))
                .observe(on_collapse_click)
                .id();
            commands.entity(container_entity).add_child(collapse_entity);
        }
    }
}

fn on_add_click(
    event: On<ButtonClickEvent>,
    add_buttons: Query<&PanelSectionAddButton>,
    mut commands: Commands,
) {
    let Ok(add_button) = add_buttons.get(event.entity) else {
        return;
    };
    commands.trigger(ButtonClickEvent {
        entity: add_button.0,
    });
}

fn on_collapse_click(
    event: On<ButtonClickEvent>,
    collapse_buttons: Query<&PanelSectionCollapseButton>,
    mut sections: Query<(&mut Collapsed, &Children), With<EditorPanelSection>>,
    mut nodes: Query<&mut Node, Without<PanelSectionHeader>>,
    headers: Query<Entity, With<PanelSectionHeader>>,
    mut button_transforms: Query<&mut UiTransform>,
) {
    let button_entity = event.entity;
    let Ok(collapse_button) = collapse_buttons.get(button_entity) else {
        return;
    };

    let Ok((mut collapsed, section_children)) = sections.get_mut(collapse_button.0) else {
        return;
    };

    collapsed.0 = !collapsed.0;

    for child in section_children.iter() {
        if headers.get(child).is_ok() {
            continue;
        }
        if let Ok(mut node) = nodes.get_mut(child) {
            node.display = if collapsed.0 {
                Display::None
            } else {
                Display::Flex
            };
        }
    }

    if let Ok(mut transform) = button_transforms.get_mut(button_entity) {
        transform.rotation = if collapsed.0 {
            Rot2::degrees(0.0)
        } else {
            Rot2::degrees(180.0)
        };
    }
}