bevy_sprinkles_editor 0.2.0

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

use super::checkbox::{CheckboxProps, checkbox};
use super::combobox::{ComboBoxOptionData, combobox};
use super::curve_edit::{CurveEditProps, curve_edit};
use super::gradient_edit::{GradientEditProps, gradient_edit};
use super::text_edit::{TextEditPrefix, TextEditProps, text_edit};
use super::vector_edit::{VectorEditProps, VectorSuffixes, vector_edit};
use crate::ui::components::binding::{BindingTarget, FieldBinding};
use crate::ui::components::inspector::{ComboBoxOption, FieldKind, path_to_label};

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

pub struct InspectorFieldProps {
    path: String,
    kind: FieldKind,
    target: BindingTarget,
    label: Option<String>,
    icon: Option<String>,
    suffix: Option<String>,
    placeholder: Option<String>,
    min: Option<f32>,
    max: Option<f32>,
    combobox_options: Option<Vec<ComboBoxOptionData>>,
}

impl InspectorFieldProps {
    pub fn new(path: impl Into<String>) -> Self {
        Self {
            path: path.into(),
            kind: FieldKind::F32,
            target: BindingTarget::Inspected,
            label: None,
            icon: None,
            suffix: None,
            placeholder: None,
            min: None,
            max: None,
            combobox_options: None,
        }
    }

    pub fn with_target(mut self, target: BindingTarget) -> Self {
        self.target = target;
        self
    }

    pub fn percent(mut self) -> Self {
        self.kind = FieldKind::F32Percent;
        self
    }

    pub fn u32(mut self) -> Self {
        self.kind = FieldKind::U32;
        self
    }

    pub fn u32_or_empty(mut self) -> Self {
        self.kind = FieldKind::U32OrEmpty;
        self
    }

    pub fn optional_u32(mut self) -> Self {
        self.kind = FieldKind::OptionalU32;
        self
    }

    pub fn bool(mut self) -> Self {
        self.kind = FieldKind::Bool;
        self
    }

    pub fn vector(mut self, suffixes: VectorSuffixes) -> Self {
        self.kind = FieldKind::Vector(suffixes);
        self
    }

    pub fn curve(mut self) -> Self {
        self.kind = FieldKind::Curve;
        self
    }

    pub fn gradient(mut self) -> Self {
        self.kind = FieldKind::Gradient;
        self
    }

    pub fn combobox(self, options: Vec<ComboBoxOptionData>) -> Self {
        self.set_combobox(options, false)
    }

    pub fn optional_combobox(self, options: Vec<ComboBoxOptionData>) -> Self {
        self.set_combobox(options, true)
    }

    fn set_combobox(mut self, options: Vec<ComboBoxOptionData>, optional: bool) -> Self {
        self.kind = FieldKind::ComboBox {
            options: combobox_data_to_options(&options),
            optional,
        };
        self.combobox_options = Some(options);
        self
    }

    pub fn with_icon(mut self, path: impl Into<String>) -> Self {
        self.icon = Some(path.into());
        self
    }

    pub fn with_label(mut self, label: impl Into<String>) -> Self {
        self.label = Some(label.into());
        self
    }

    pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
        self.suffix = Some(suffix.into());
        self
    }

    pub fn with_placeholder(mut self, placeholder: impl Into<String>) -> Self {
        self.placeholder = Some(placeholder.into());
        self
    }

    pub fn with_min(mut self, min: f32) -> Self {
        self.min = Some(min);
        self
    }

    pub fn with_max(mut self, max: f32) -> Self {
        self.max = Some(max);
        self
    }

    fn inferred_label(&self) -> String {
        self.label
            .clone()
            .unwrap_or_else(|| path_to_label(&self.path))
    }

    fn inferred_suffix(&self) -> Option<&str> {
        if self.suffix.is_some() {
            return self.suffix.as_deref();
        }
        match self.kind {
            FieldKind::F32Percent => Some("%"),
            _ => None,
        }
    }

    fn inferred_min(&self) -> Option<f32> {
        if self.min.is_some() {
            return self.min;
        }
        match self.kind {
            FieldKind::F32Percent
            | FieldKind::U32
            | FieldKind::U32OrEmpty
            | FieldKind::OptionalU32 => Some(0.0),
            _ => None,
        }
    }

    fn inferred_max(&self) -> Option<f32> {
        if self.max.is_some() {
            return self.max;
        }
        match self.kind {
            FieldKind::F32Percent => Some(100.0),
            _ => None,
        }
    }

    fn should_allow_empty(&self) -> bool {
        matches!(self.kind, FieldKind::U32OrEmpty | FieldKind::OptionalU32)
    }

    fn is_integer(&self) -> bool {
        matches!(
            self.kind,
            FieldKind::U32 | FieldKind::U32OrEmpty | FieldKind::OptionalU32
        )
    }
}

pub fn spawn_inspector_field(
    spawner: &mut ChildSpawnerCommands,
    props: InspectorFieldProps,
    asset_server: &AssetServer,
) {
    let field = match props.target {
        BindingTarget::Asset => FieldBinding::asset(&props.path, props.kind.clone()),
        BindingTarget::EditorSettings => {
            FieldBinding::editor_settings(&props.path, props.kind.clone())
        }
        BindingTarget::Inspected => FieldBinding::emitter(&props.path, props.kind.clone()),
    };
    let label = props.inferred_label();

    if props.kind == FieldKind::Bool {
        spawner.spawn((field, checkbox(CheckboxProps::new(label), asset_server)));
        return;
    }

    if let FieldKind::Vector(suffixes) = props.kind {
        let mut vec_props = VectorEditProps::default()
            .with_label(label)
            .with_size(suffixes.vector_size())
            .with_suffixes(suffixes);

        if let Some(suffix) = props.inferred_suffix() {
            vec_props = vec_props.with_suffix(suffix);
        }
        if let Some(min) = props.inferred_min() {
            vec_props = vec_props.with_min(min as f64);
        }
        if let Some(max) = props.inferred_max() {
            vec_props = vec_props.with_max(max as f64);
        }

        spawner.spawn((field, vector_edit(vec_props)));
        return;
    }

    if props.kind == FieldKind::Curve {
        spawner.spawn((field, curve_edit(CurveEditProps::new().with_label(label))));
        return;
    }

    if props.kind == FieldKind::Gradient {
        spawner.spawn((
            field,
            gradient_edit(GradientEditProps::new().with_label(label)),
        ));
        return;
    }

    if let Some(options) = props.combobox_options {
        spawner.spawn((field, combobox_field(label, options)));
        return;
    }

    let mut text_props = TextEditProps::default().with_label(label);

    if props.is_integer() {
        text_props = text_props.numeric_i32();
    } else {
        text_props = text_props.numeric_f32();
    }

    if let Some(suffix) = props.inferred_suffix() {
        text_props = text_props.with_suffix(suffix);
    }

    if let Some(ref placeholder) = props.placeholder {
        text_props = text_props.with_placeholder(placeholder);
    }

    if let Some(ref icon) = props.icon {
        text_props = text_props.with_prefix(TextEditPrefix::Icon { path: icon.clone() });
    }

    if let Some(min) = props.inferred_min() {
        text_props = text_props.with_min(min as f64);
    }

    if let Some(max) = props.inferred_max() {
        text_props = text_props.with_max(max as f64);
    }

    if props.should_allow_empty() {
        text_props = text_props.allow_empty();
    }

    spawner.spawn((field, text_edit(text_props)));
}

fn combobox_data_to_options(data: &[ComboBoxOptionData]) -> Vec<ComboBoxOption> {
    data.iter()
        .map(|o| {
            let value = o.value.clone().unwrap_or_else(|| o.label.clone());
            ComboBoxOption::new(o.label.clone(), value)
        })
        .collect()
}

#[derive(Component)]
pub(crate) struct ComboBoxFieldConfig {
    label: String,
    options: Vec<ComboBoxOptionData>,
    initialized: bool,
}

pub(crate) fn combobox_field(label: String, options: Vec<ComboBoxOptionData>) -> impl Bundle {
    (
        ComboBoxFieldConfig {
            label,
            options,
            initialized: false,
        },
        Node {
            flex_direction: FlexDirection::Column,
            row_gap: Val::Px(3.0),
            flex_grow: 1.0,
            flex_shrink: 1.0,
            flex_basis: Val::Px(0.0),
            ..default()
        },
    )
}

pub fn setup_combobox_fields(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut configs: Query<(Entity, &mut ComboBoxFieldConfig)>,
) {
    let font: Handle<Font> = asset_server.load(crate::ui::tokens::FONT_PATH);

    for (entity, mut config) in &mut configs {
        if config.initialized {
            continue;
        }
        config.initialized = true;

        let label_entity = commands
            .spawn((
                Text::new(&config.label),
                TextFont {
                    font: font.clone(),
                    font_size: 11.0,
                    weight: FontWeight::MEDIUM,
                    ..default()
                },
                TextColor(crate::ui::tokens::TEXT_MUTED_COLOR.into()),
            ))
            .id();

        let combobox_entity = commands.spawn(combobox(config.options.clone())).id();

        commands
            .entity(entity)
            .add_children(&[label_entity, combobox_entity]);
    }
}

pub fn fields_row() -> impl Bundle {
    Node {
        width: Val::Percent(100.0),
        column_gap: Val::Px(12.0),
        ..default()
    }
}