bevy_sprinkles_editor 0.1.3

GPU particle system editor for Bevy
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use bevy::prelude::*;
use bevy::tasks::IoTaskPool;
use bevy_sprinkles::asset::versioning::VersionStatus;
use bevy_sprinkles::prelude::*;
use inflector::Inflector;

use crate::io::{EditorData, project_path, projects_dir, save_editor_data, simplify_path};
use crate::state::{DirtyState, EditorState, Inspectable, Inspecting};
use crate::ui::components::toasts::ToastEvent;

pub fn plugin(app: &mut App) {
    app.add_observer(on_open_project_event)
        .add_observer(on_browse_open_project_event)
        .add_observer(on_save_project_event)
        .add_observer(on_save_project_as_event)
        .add_systems(
            Update,
            (
                handle_save_keyboard_shortcut,
                poll_browse_open_result,
                poll_save_as_result,
                poll_save_result,
            ),
        );
}

#[derive(Event)]
pub struct OpenProjectEvent(pub String);

#[derive(Event)]
pub struct BrowseOpenProjectEvent;

#[derive(Event)]
pub struct SaveProjectEvent;

#[derive(Event)]
pub struct SaveProjectAsEvent;

#[derive(Resource, Clone)]
pub struct BrowseOpenResult(pub Arc<Mutex<Option<PathBuf>>>);

#[derive(Resource, Clone)]
pub struct SaveAsResult(pub Arc<Mutex<Option<PathBuf>>>);

#[derive(Clone)]
pub enum SaveResultStatus {
    Success(String),
    SerializationError,
    WriteError(String),
    CreateError,
}

#[derive(Resource, Clone)]
pub struct SaveResult(pub Arc<Mutex<Option<SaveResultStatus>>>);

pub fn load_project_from_path(
    path: &std::path::Path,
) -> Option<bevy_sprinkles::asset::ParticleSystemAsset> {
    let contents = std::fs::read_to_string(path).ok()?;
    ron::from_str(&contents).ok()
}

fn on_open_project_event(
    event: On<OpenProjectEvent>,
    mut editor_state: ResMut<EditorState>,
    mut editor_data: ResMut<EditorData>,
    mut assets: ResMut<Assets<ParticleSystemAsset>>,
    mut dirty_state: ResMut<DirtyState>,
    mut commands: Commands,
) {
    let location = &event.0;
    let path = project_path(location);

    let Some(mut asset) = load_project_from_path(&path) else {
        commands.trigger(ToastEvent::error(format!(
            "Failed to open project: {location}"
        )));
        return;
    };

    let filename = path
        .file_name()
        .map(|n| n.to_string_lossy().to_string())
        .unwrap_or_else(|| location.clone());

    match asset.try_upgrade_version() {
        VersionStatus::Current => {}
        VersionStatus::Outdated { current, .. } => {
            dirty_state.has_unsaved_changes = true;
            commands.trigger(ToastEvent::success(format!(
                "Project \"{filename}\" will be updated to {current} on the next save"
            )));
        }
        VersionStatus::Incompatible { found, current } => {
            commands.trigger(ToastEvent::error(format!(
                "Project \"{filename}\" (version {found}) is incompatible with Sprinkles {current}"
            )));
            return;
        }
        VersionStatus::Unknown => {
            commands.trigger(ToastEvent::error(format!(
                "Project \"{filename}\" has an unknown version. You may need to update Sprinkles."
            )));
            return;
        }
    }

    let has_emitters = !asset.emitters.is_empty();
    let handle = assets.add(asset);
    editor_state.open_project(handle, path, &mut dirty_state);
    editor_state.inspecting = if has_emitters {
        Some(Inspecting {
            kind: Inspectable::Emitter,
            index: 0,
        })
    } else {
        None
    };

    editor_data.cache.add_recent_project(location.clone());
    save_editor_data(&editor_data);
}

fn on_browse_open_project_event(_event: On<BrowseOpenProjectEvent>, mut commands: Commands) {
    let projects_dir = projects_dir();

    let path_result = Arc::new(Mutex::new(None));
    let path_result_clone = path_result.clone();

    let task = rfd::AsyncFileDialog::new()
        .set_title("Open Project")
        .set_directory(&projects_dir)
        .add_filter("RON files", &["ron"])
        .pick_file();

    IoTaskPool::get()
        .spawn(async move {
            if let Some(file_handle) = task.await {
                let path = file_handle.path().to_path_buf();
                if let Ok(mut guard) = path_result_clone.lock() {
                    *guard = Some(path);
                }
            }
        })
        .detach();

    commands.insert_resource(BrowseOpenResult(path_result));
}

fn poll_browse_open_result(result: Option<Res<BrowseOpenResult>>, mut commands: Commands) {
    let Some(result) = result else {
        return;
    };

    let path = {
        let Ok(mut guard) = result.0.lock() else {
            return;
        };
        guard.take()
    };

    if let Some(path) = path {
        commands.trigger(OpenProjectEvent(simplify_path(&path)));
        commands.remove_resource::<BrowseOpenResult>();
    }
}

pub fn save_project_to_path(
    path: PathBuf,
    asset: &bevy_sprinkles::asset::ParticleSystemAsset,
    result: Arc<Mutex<Option<SaveResultStatus>>>,
) {
    let Ok(contents) = ron::ser::to_string_pretty(asset, ron::ser::PrettyConfig::default()) else {
        if let Ok(mut guard) = result.lock() {
            *guard = Some(SaveResultStatus::SerializationError);
        }
        return;
    };

    IoTaskPool::get()
        .spawn(async move {
            let status = match File::create(&path) {
                Ok(mut file) => {
                    let filename = path
                        .file_name()
                        .map(|n| n.to_string_lossy().to_string())
                        .unwrap_or_else(|| "".to_string());
                    if file.write_all(contents.as_bytes()).is_ok() {
                        SaveResultStatus::Success(filename)
                    } else {
                        SaveResultStatus::WriteError(filename)
                    }
                }
                Err(_) => SaveResultStatus::CreateError,
            };
            if let Ok(mut guard) = result.lock() {
                *guard = Some(status);
            }
        })
        .detach();
}

fn on_save_project_event(
    _event: On<SaveProjectEvent>,
    editor_state: Res<EditorState>,
    assets: Res<Assets<ParticleSystemAsset>>,
    mut dirty_state: ResMut<DirtyState>,
    mut commands: Commands,
) {
    let Some(handle) = &editor_state.current_project else {
        return;
    };
    let Some(asset) = assets.get(handle) else {
        return;
    };

    if let Some(path) = &editor_state.current_project_path {
        let result = Arc::new(Mutex::new(None));
        save_project_to_path(path.clone(), asset, result.clone());
        commands.insert_resource(SaveResult(result));
        dirty_state.has_unsaved_changes = false;
    } else {
        commands.trigger(SaveProjectAsEvent);
    }
}

fn on_save_project_as_event(
    _event: On<SaveProjectAsEvent>,
    editor_state: Res<EditorState>,
    assets: Res<Assets<ParticleSystemAsset>>,
    mut commands: Commands,
) {
    let Some(handle) = &editor_state.current_project else {
        return;
    };
    let Some(asset) = assets.get(handle) else {
        return;
    };

    let projects_dir = projects_dir();
    let default_name = format!("{}.ron", asset.name.to_kebab_case());
    let asset_clone = asset.clone();

    let path_result = Arc::new(Mutex::new(None));
    let path_result_clone = path_result.clone();

    let save_result = Arc::new(Mutex::new(None));
    let save_result_clone = save_result.clone();

    let task = rfd::AsyncFileDialog::new()
        .set_title("Save Project As")
        .set_directory(&projects_dir)
        .set_file_name(&default_name)
        .add_filter("RON files", &["ron"])
        .save_file();

    IoTaskPool::get()
        .spawn(async move {
            if let Some(file_handle) = task.await {
                let path = file_handle.path().to_path_buf();
                save_project_to_path(path.clone(), &asset_clone, save_result_clone);
                if let Ok(mut guard) = path_result_clone.lock() {
                    *guard = Some(path);
                }
            }
        })
        .detach();

    commands.insert_resource(SaveAsResult(path_result));
    commands.insert_resource(SaveResult(save_result));
}

fn poll_save_as_result(
    result: Option<Res<SaveAsResult>>,
    mut editor_state: ResMut<EditorState>,
    mut editor_data: ResMut<EditorData>,
    mut dirty_state: ResMut<DirtyState>,
    mut commands: Commands,
) {
    let Some(result) = result else {
        return;
    };

    let path = {
        let Ok(mut guard) = result.0.lock() else {
            return;
        };
        guard.take()
    };

    if let Some(path) = path {
        editor_state.current_project_path = Some(path.clone());

        editor_data.cache.add_recent_project(simplify_path(&path));
        save_editor_data(&editor_data);
        dirty_state.has_unsaved_changes = false;
        commands.remove_resource::<SaveAsResult>();
    }
}

fn poll_save_result(result: Option<Res<SaveResult>>, mut commands: Commands) {
    let Some(result) = result else {
        return;
    };

    let status = {
        let Ok(mut guard) = result.0.lock() else {
            return;
        };
        guard.take()
    };

    if let Some(status) = status {
        match status {
            SaveResultStatus::Success(filename) => {
                commands.trigger(ToastEvent::success(format!("Saved \"{filename}\"")));
            }
            SaveResultStatus::SerializationError => {
                commands.trigger(ToastEvent::error("Cannot save project with invalid data"));
            }
            SaveResultStatus::WriteError(filename) => {
                commands.trigger(ToastEvent::error(format!(
                    "Failed to write to \"{filename}\""
                )));
            }
            SaveResultStatus::CreateError => {
                commands.trigger(ToastEvent::error("Failed to create project file"));
            }
        }
        commands.remove_resource::<SaveResult>();
    }
}

fn handle_save_keyboard_shortcut(keyboard: Res<ButtonInput<KeyCode>>, mut commands: Commands) {
    let ctrl_or_cmd = keyboard.pressed(KeyCode::SuperLeft)
        || keyboard.pressed(KeyCode::SuperRight)
        || keyboard.pressed(KeyCode::ControlLeft)
        || keyboard.pressed(KeyCode::ControlRight);

    if ctrl_or_cmd && keyboard.just_pressed(KeyCode::KeyS) {
        commands.trigger(SaveProjectEvent);
    }
}