bevy_sprinkles_editor 0.2.0

GPU particle system editor for Bevy
use bevy::color::palettes::tailwind;
use bevy::prelude::*;
use bevy::text::{FontFeatureTag, FontFeatures};
use bevy_sprinkles::prelude::*;

use crate::state::PlaybackSeekEvent;
use crate::ui::tokens::{FONT_PATH, TEXT_MUTED_COLOR};
use crate::viewport::EditorParticlePreview;

const SEEKBAR_HEIGHT: f32 = 4.0;
const SEEKBAR_WIDTH: f32 = 192.0;
const LABEL_SIZE: f32 = 12.0;

pub fn plugin(app: &mut App) {
    app.add_systems(Update, (update_seekbar, setup_seekbar_observers))
        .add_observer(on_seekbar_drag);
}

#[derive(Component)]
pub struct EditorSeekbar;

#[derive(Component)]
pub struct SeekbarElapsed;

#[derive(Component)]
pub struct SeekbarDuration;

#[derive(Component)]
pub struct SeekbarHitbox;

#[derive(Component)]
pub struct SeekbarTrack;

#[derive(Component)]
pub struct SeekbarFill;

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

#[derive(EntityEvent)]
pub struct SeekbarDragEvent {
    pub entity: Entity,
    pub value: f32,
}

pub fn seekbar(asset_server: &AssetServer) -> impl Bundle {
    let font: Handle<Font> = asset_server.load(FONT_PATH);
    let tabular_figures: FontFeatures = [FontFeatureTag::TABULAR_FIGURES].into();

    (
        EditorSeekbar,
        Node {
            align_items: AlignItems::Center,
            column_gap: px(6),
            ..default()
        },
        children![
            (
                SeekbarElapsed,
                Text::new("0.00"),
                TextFont {
                    font: font.clone().into(),
                    font_size: LABEL_SIZE,
                    font_features: tabular_figures.clone(),
                    weight: FontWeight::MEDIUM,
                    ..default()
                },
                TextColor(TEXT_MUTED_COLOR.into()),
            ),
            (
                Node {
                    width: px(SEEKBAR_WIDTH),
                    height: px(SEEKBAR_HEIGHT),
                    ..default()
                },
                children![
                    (
                        SeekbarTrack,
                        Node {
                            width: percent(100),
                            height: percent(100),
                            border_radius: BorderRadius::all(Val::Percent(100.0)),
                            overflow: Overflow::clip(),
                            ..default()
                        },
                        BackgroundColor(tailwind::ZINC_700.into()),
                        children![(
                            SeekbarFill,
                            Node {
                                width: percent(0),
                                height: percent(100),
                                border_radius: BorderRadius::all(Val::Percent(100.0)),
                                ..default()
                            },
                            BackgroundColor(tailwind::ZINC_200.into()),
                        )],
                    ),
                    (
                        SeekbarHitbox,
                        SeekbarDragState::default(),
                        Node {
                            position_type: PositionType::Absolute,
                            width: px(SEEKBAR_WIDTH),
                            height: px(SEEKBAR_HEIGHT * 3.),
                            top: px(-SEEKBAR_HEIGHT),
                            justify_content: JustifyContent::Center,
                            align_items: AlignItems::Center,
                            ..default()
                        },
                    ),
                ],
            ),
            (
                SeekbarDuration,
                Text::new("0.00s"),
                TextFont {
                    font: font.into(),
                    font_size: LABEL_SIZE,
                    font_features: tabular_figures,
                    weight: FontWeight::MEDIUM,
                    ..default()
                },
                TextColor(TEXT_MUTED_COLOR.into()),
            ),
        ],
    )
}

fn format_time(seconds: f32) -> String {
    format!("{:.2}", seconds)
}

fn format_duration(seconds: f32) -> String {
    format!("{:.2}s", seconds)
}

fn setup_seekbar_observers(hitboxes: Query<Entity, Added<SeekbarHitbox>>, mut commands: Commands) {
    for entity in &hitboxes {
        commands
            .entity(entity)
            .observe(on_drag_start)
            .observe(on_drag)
            .observe(on_drag_end);
    }
}

fn update_seekbar(
    assets: Res<Assets<ParticleSystemAsset>>,
    system_query: Query<(Entity, &ParticleSystem3D), With<EditorParticlePreview>>,
    emitter_query: Query<(&EmitterEntity, &EmitterRuntime)>,
    mut elapsed_label: Query<&mut Text, (With<SeekbarElapsed>, Without<SeekbarDuration>)>,
    mut duration_label: Query<&mut Text, (With<SeekbarDuration>, Without<SeekbarElapsed>)>,
    mut fill: Query<&mut Node, With<SeekbarFill>>,
    drag_state: Query<&SeekbarDragState, With<SeekbarHitbox>>,
) {
    let Ok(drag) = drag_state.single() else {
        return;
    };

    let Some((system_entity, particle_system)) = system_query.iter().next() else {
        return;
    };

    let Some(asset) = assets.get(&particle_system.handle) else {
        return;
    };

    let sub_target_indices: Vec<usize> = asset
        .emitters
        .iter()
        .filter_map(|e| e.sub_emitter.as_ref().map(|s| s.target_emitter))
        .collect();

    let duration = asset
        .emitters
        .iter()
        .enumerate()
        .filter(|(idx, _)| !sub_target_indices.contains(idx))
        .map(|(_, e)| e.time.total_duration())
        .fold(0.0_f32, |a, b| a.max(b));

    let elapsed = if drag.dragging {
        drag.drag_time
    } else {
        emitter_query
            .iter()
            .filter(|(e, r)| {
                e.parent_system == system_entity && !sub_target_indices.contains(&r.emitter_index)
            })
            .map(|(_, r)| r.system_time)
            .fold(0.0_f32, |a, b| a.max(b))
    };

    for mut text in &mut elapsed_label {
        **text = format_time(elapsed);
    }

    for mut text in &mut duration_label {
        **text = format_duration(duration);
    }

    if drag.dragging {
        return;
    }

    let progress = if duration > 0.0 {
        (elapsed / duration).clamp(0.0, 1.0)
    } else {
        0.0
    };

    for mut node in &mut fill {
        node.width = Val::Percent(progress * 100.0);
    }
}

fn on_drag_start(
    event: On<Pointer<DragStart>>,
    mut hitboxes: Query<&mut SeekbarDragState, With<SeekbarHitbox>>,
) {
    let Ok(mut drag_state) = hitboxes.get_mut(event.entity) else {
        return;
    };
    drag_state.dragging = true;
}

fn on_drag(
    event: On<Pointer<Drag>>,
    hitboxes: Query<(&SeekbarDragState, &ComputedNode, &UiGlobalTransform), With<SeekbarHitbox>>,
    mut fill: Query<&mut Node, With<SeekbarFill>>,
    mut commands: Commands,
) {
    let entity = event.entity;
    let Ok((drag_state, computed, transform)) = hitboxes.get(entity) else {
        return;
    };

    if !drag_state.dragging {
        return;
    }

    let pointer_x = event.pointer_location.position.x;
    let scale = computed.inverse_scale_factor;
    let center_x = transform.translation.x * scale;
    let width = computed.size.x * scale;
    let left_x = center_x - width * 0.5;
    let value = ((pointer_x - left_x) / width).clamp(0.0, 1.0);

    for mut node in &mut fill {
        node.width = Val::Percent(value * 100.0);
    }

    commands.trigger(SeekbarDragEvent { entity, value });
}

fn on_drag_end(
    event: On<Pointer<DragEnd>>,
    mut hitboxes: Query<&mut SeekbarDragState, With<SeekbarHitbox>>,
) {
    let entity = event.entity;
    let Ok(mut drag_state) = hitboxes.get_mut(entity) else {
        return;
    };

    drag_state.dragging = false;
}

fn on_seekbar_drag(
    event: On<SeekbarDragEvent>,
    mut commands: Commands,
    assets: Res<Assets<ParticleSystemAsset>>,
    system_query: Query<&ParticleSystem3D, With<EditorParticlePreview>>,
    mut hitboxes: Query<&mut SeekbarDragState, With<SeekbarHitbox>>,
) {
    let Some(particle_system) = system_query.iter().next() else {
        return;
    };

    let Some(asset) = assets.get(&particle_system.handle) else {
        return;
    };

    let sub_target_indices: Vec<usize> = asset
        .emitters
        .iter()
        .filter_map(|e| e.sub_emitter.as_ref().map(|s| s.target_emitter))
        .collect();

    let duration = asset
        .emitters
        .iter()
        .enumerate()
        .filter(|(idx, _)| !sub_target_indices.contains(idx))
        .map(|(_, e)| e.time.total_duration())
        .fold(0.0_f32, |a, b| a.max(b));

    let seek_time = event.value * duration;

    if let Ok(mut drag_state) = hitboxes.get_mut(event.entity) {
        drag_state.drag_time = seek_time;
    }

    commands.trigger(PlaybackSeekEvent(seek_time));
}