Compare commits
2 Commits
61be2440bd
...
ba59f57a5a
| Author | SHA1 | Date | |
|---|---|---|---|
| ba59f57a5a | |||
| d038cf3670 |
36
.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md
Normal file
36
.cursor/plans/non_blocking_native_dialogs_2026-07-12.plan.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Non-Blocking Native Dialogs
|
||||||
|
|
||||||
|
Working implementation plan for the production-readiness defect discovered during live acceptance
|
||||||
|
of collaborative file safety. Opening an `rfd` picker synchronously from an egui/Bevy system stops
|
||||||
|
window event processing long enough for Hyprland to report Blacksite as unresponsive.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
1. Add one project-level native-dialog broker with a typed intent, one active dialog, background
|
||||||
|
native work, and a main-thread result queue.
|
||||||
|
2. Convert scene open/save-as, asset import/export, prefab save/source selection, subscene selection,
|
||||||
|
conflict Save As, diagnostics export, and project-launcher folder selection to the broker.
|
||||||
|
3. Keep destructive or stateful application logic on the Bevy main thread after a path result is
|
||||||
|
received. Background work may only own the native dialog and its returned paths.
|
||||||
|
4. Show a stable pending state and reject duplicate dialog requests while a picker is active.
|
||||||
|
5. Replace blocking native dirty-scene confirmation dialogs with an editor-owned modal when the
|
||||||
|
scene-transition state machine can preserve the original action exactly.
|
||||||
|
|
||||||
|
## Contract
|
||||||
|
|
||||||
|
- The editor continues rendering and processing compositor pings while a native dialog is open.
|
||||||
|
- Cancel returns a typed empty result and does not mutate editor state.
|
||||||
|
- A result is applied once to the intent that created it; stale or duplicate results cannot run.
|
||||||
|
- Paths are validated at the same ownership boundary as before. The dialog broker does not read,
|
||||||
|
write, import, save, or canonicalize project content.
|
||||||
|
- Existing collaborative revision guards remain authoritative after Save As selection.
|
||||||
|
- Project-launcher dialogs use the same broker before the full editor plugin group is active.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Focused tests cover request exclusivity, cancel, one-shot result delivery, and typed intent
|
||||||
|
preservation.
|
||||||
|
- Source-only workspace formatting, check, strict Clippy, and tests run; packaged acceptance remains
|
||||||
|
deferred by project-owner direction.
|
||||||
|
- Native Wayland QA opens representative file, folder, and Save As dialogs under Hyprland and
|
||||||
|
confirms the Blacksite window remains responsive without an unresponsive-application prompt.
|
||||||
@ -163,7 +163,7 @@ deep-stale variants.
|
|||||||
| Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root |
|
| Drag actor between rows / onto Scene Root (Hierarchy, Manual sort) | Reorder siblings / unparent to the root |
|
||||||
| Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions |
|
| Hierarchy lock | Excludes the actor from selection, gizmos, multi-drag, structural drops, and mutating context actions |
|
||||||
| Hierarchy context | Group selection, create authored local children below linked prefab roots, remove/reparent generated members through same-layer structural overrides, or unparent |
|
| Hierarchy context | Group selection, create authored local children below linked prefab roots, remove/reparent generated members through same-layer structural overrides, or unparent |
|
||||||
| File menu | New, Open, transactional Save/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
|
| File menu | New, non-blocking native Open/Save As, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes |
|
||||||
| Status strip / Asset Details source-state chip | Inspect compact clean, modified, untracked, conflicted, read-only, and optional ownership status; hover for the source path and provider details |
|
| Status strip / Asset Details source-state chip | Inspect compact clean, modified, untracked, conflicted, read-only, and optional ownership status; hover for the source path and provider details |
|
||||||
| Authored File Not Saved dialog | Resolve an external edit, read-only target, or ownership lock with Reload, Compare Metadata, Save As, or Cancel; the editor never offers force overwrite |
|
| Authored File Not Saved dialog | Resolve an external edit, read-only target, or ownership lock with Reload, Compare Metadata, Save As, or Cancel; the editor never offers force overwrite |
|
||||||
| Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes |
|
| Main toolbar, right side | Switch or close independent scene tabs, create an untitled tab, and manage loaded/locked composed subscenes |
|
||||||
@ -398,6 +398,7 @@ crates/
|
|||||||
- [x] Filtered hierarchy, inspector, viewport, toolbar, asset browser, and status panels
|
- [x] Filtered hierarchy, inspector, viewport, toolbar, asset browser, and status panels
|
||||||
- [x] Delete, duplicate, rename, and structural/material undo-redo
|
- [x] Delete, duplicate, rename, and structural/material undo-redo
|
||||||
- [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking
|
- [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking
|
||||||
|
- [x] Non-blocking native file/folder/confirmation broker across scene, asset, prefab, composition, collaboration, and Project Browser workflows ([ADR 0038](docs/adr/0038-non-blocking-native-dialog-broker.md), [workflow guide](docs/editor/native-dialogs.md), [Gitea #52](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/52))
|
||||||
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
|
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
|
||||||
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
||||||
- [x] Unified viewport render-to-texture target + Play session bootstrap
|
- [x] Unified viewport render-to-texture target + Play session bootstrap
|
||||||
|
|||||||
@ -22,6 +22,7 @@ use crate::asset_db::{ensure_asset_record, AssetRegistry};
|
|||||||
use crate::history::{
|
use crate::history::{
|
||||||
capture_prefab_edit_state, record_prefab_edit_state, record_prefab_source_apply,
|
capture_prefab_edit_state, record_prefab_edit_state, record_prefab_source_apply,
|
||||||
};
|
};
|
||||||
|
use crate::native_dialog::NativeDialogBroker;
|
||||||
use crate::ui::inspector::{
|
use crate::ui::inspector::{
|
||||||
apply_component_card_response, component_card, component_card_context, property_row,
|
apply_component_card_response, component_card, component_card_context, property_row,
|
||||||
ComponentCardOptions,
|
ComponentCardOptions,
|
||||||
@ -751,13 +752,44 @@ fn relink_prefab_source(world: &mut World, entity: Entity) {
|
|||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(path) = rfd::FileDialog::new()
|
let directory = project_root.join("assets/prefabs");
|
||||||
.set_directory(project_root.join("assets/prefabs"))
|
let result = world.resource::<NativeDialogBroker>().request(
|
||||||
.add_filter("Bevy prefab", &["scn.ron", "ron"])
|
move || {
|
||||||
.pick_file()
|
rfd::FileDialog::new()
|
||||||
else {
|
.set_directory(directory)
|
||||||
|
.add_filter("Bevy prefab", &["scn.ron", "ron"])
|
||||||
|
.pick_file()
|
||||||
|
},
|
||||||
|
move |world, path| {
|
||||||
|
let Some(path) = path else {
|
||||||
|
world
|
||||||
|
.resource_mut::<crate::scene_io::SceneIo>()
|
||||||
|
.set_status("Prefab relink cancelled");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
finish_relink_prefab_source(world, entity, project_root, path);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
world
|
||||||
|
.resource_mut::<crate::scene_io::SceneIo>()
|
||||||
|
.set_status(match result {
|
||||||
|
Ok(()) => "Choose a prefab source".into(),
|
||||||
|
Err(error) => format!("Prefab relink unavailable: {error}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_relink_prefab_source(
|
||||||
|
world: &mut World,
|
||||||
|
entity: Entity,
|
||||||
|
project_root: PathBuf,
|
||||||
|
path: PathBuf,
|
||||||
|
) {
|
||||||
|
if world.get_entity(entity).is_err() {
|
||||||
|
world
|
||||||
|
.resource_mut::<crate::scene_io::SceneIo>()
|
||||||
|
.set_status("Prefab relink failed: the initiating actor no longer exists");
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let Ok(path) = path.canonicalize() else {
|
let Ok(path) = path.canonicalize() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -25,6 +25,7 @@ pub use play::state;
|
|||||||
pub use project::collaboration;
|
pub use project::collaboration;
|
||||||
pub use project::diagnostics_bundle;
|
pub use project::diagnostics_bundle;
|
||||||
pub use project::launcher;
|
pub use project::launcher;
|
||||||
|
pub use project::native_dialog;
|
||||||
pub use project::project_io;
|
pub use project::project_io;
|
||||||
pub use project::session;
|
pub use project::session;
|
||||||
pub use project::settings_ui;
|
pub use project::settings_ui;
|
||||||
@ -68,6 +69,7 @@ use operators::OperatorPlugin;
|
|||||||
use play::audio_preview::AudioPreviewPlugin;
|
use play::audio_preview::AudioPreviewPlugin;
|
||||||
use play::PlaySessionPlugin;
|
use play::PlaySessionPlugin;
|
||||||
use project::collaboration::CollaborationPlugin;
|
use project::collaboration::CollaborationPlugin;
|
||||||
|
use project::native_dialog::NativeDialogPlugin;
|
||||||
use project_io::ProjectIoPlugin;
|
use project_io::ProjectIoPlugin;
|
||||||
use render_view::RenderViewPlugin;
|
use render_view::RenderViewPlugin;
|
||||||
use scene_io::SceneIoPlugin;
|
use scene_io::SceneIoPlugin;
|
||||||
@ -90,6 +92,7 @@ impl PluginGroup for EditorPluginGroup {
|
|||||||
let group = PluginGroupBuilder::start::<Self>()
|
let group = PluginGroupBuilder::start::<Self>()
|
||||||
.add(EditorInfraPlugin)
|
.add(EditorInfraPlugin)
|
||||||
.add(ProjectIoPlugin)
|
.add(ProjectIoPlugin)
|
||||||
|
.add(NativeDialogPlugin)
|
||||||
.add(scene_schema::SceneSchemaPlugin)
|
.add(scene_schema::SceneSchemaPlugin)
|
||||||
.add(net_editor::NetEditorPlugin)
|
.add(net_editor::NetEditorPlugin)
|
||||||
.add(AssetDbPlugin)
|
.add(AssetDbPlugin)
|
||||||
|
|||||||
@ -15,6 +15,7 @@ use egui_phosphor_icons::icons;
|
|||||||
use shared::PrefabInstance;
|
use shared::PrefabInstance;
|
||||||
|
|
||||||
use crate::assets::EditorAssets;
|
use crate::assets::EditorAssets;
|
||||||
|
use crate::native_dialog::NativeDialogBroker;
|
||||||
use crate::project_io::ProjectWorkspace;
|
use crate::project_io::ProjectWorkspace;
|
||||||
use crate::scene::recovery::atomic_write_with_pre_rename;
|
use crate::scene::recovery::atomic_write_with_pre_rename;
|
||||||
use crate::scene_io::SceneIo;
|
use crate::scene_io::SceneIo;
|
||||||
@ -880,13 +881,34 @@ fn resolve_conflict_reload(world: &mut World, conflict: &PendingFileConflict) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_conflict_save_as(world: &mut World, conflict: &PendingFileConflict) {
|
fn resolve_conflict_save_as(world: &mut World, conflict: &PendingFileConflict) {
|
||||||
let directory = conflict.path.parent().unwrap_or_else(|| Path::new("."));
|
let conflict = conflict.clone();
|
||||||
|
let directory = conflict
|
||||||
|
.path
|
||||||
|
.parent()
|
||||||
|
.unwrap_or_else(|| Path::new("."))
|
||||||
|
.to_path_buf();
|
||||||
let file_name = conflict_copy_file_name(&conflict.path);
|
let file_name = conflict_copy_file_name(&conflict.path);
|
||||||
let Some(destination) = rfd::FileDialog::new()
|
let request = world.resource::<NativeDialogBroker>().request(
|
||||||
.set_directory(directory)
|
move || {
|
||||||
.set_file_name(file_name)
|
rfd::FileDialog::new()
|
||||||
.save_file()
|
.set_directory(directory)
|
||||||
else {
|
.set_file_name(file_name)
|
||||||
|
.save_file()
|
||||||
|
},
|
||||||
|
move |world, destination| finish_conflict_save_as(world, conflict, destination),
|
||||||
|
);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(match request {
|
||||||
|
Ok(()) => "Choose a destination for the conflict copy".into(),
|
||||||
|
Err(error) => format!("Save As unavailable: {error}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_conflict_save_as(
|
||||||
|
world: &mut World,
|
||||||
|
conflict: PendingFileConflict,
|
||||||
|
destination: Option<PathBuf>,
|
||||||
|
) {
|
||||||
|
let Some(destination) = destination else {
|
||||||
world
|
world
|
||||||
.resource_mut::<SceneIo>()
|
.resource_mut::<SceneIo>()
|
||||||
.set_status("Save conflict copy cancelled");
|
.set_status("Save conflict copy cancelled");
|
||||||
|
|||||||
@ -13,6 +13,7 @@ use settings::{
|
|||||||
PROJECT_TEMPLATE_VERSION,
|
PROJECT_TEMPLATE_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::native_dialog::{NativeDialogBroker, NativeDialogPlugin};
|
||||||
use crate::project_io::UserPreferences;
|
use crate::project_io::UserPreferences;
|
||||||
use crate::scene::recovery::atomic_write;
|
use crate::scene::recovery::atomic_write;
|
||||||
use crate::ui::theme::{
|
use crate::ui::theme::{
|
||||||
@ -91,6 +92,7 @@ pub fn run_project_launcher() {
|
|||||||
..default()
|
..default()
|
||||||
}))
|
}))
|
||||||
.add_plugins(EguiPlugin::default())
|
.add_plugins(EguiPlugin::default())
|
||||||
|
.add_plugins(NativeDialogPlugin)
|
||||||
.init_resource::<ProjectLauncherUi>()
|
.init_resource::<ProjectLauncherUi>()
|
||||||
.add_systems(Startup, spawn_project_launcher_camera)
|
.add_systems(Startup, spawn_project_launcher_camera)
|
||||||
.add_systems(EguiPrimaryContextPass, project_launcher_ui_system);
|
.add_systems(EguiPrimaryContextPass, project_launcher_ui_system);
|
||||||
@ -208,9 +210,24 @@ fn launcher_actions(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLau
|
|||||||
egui::RichText::new("Select a root containing assets/project.ron").color(TEXT_DIM),
|
egui::RichText::new("Select a root containing assets/project.ron").color(TEXT_DIM),
|
||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
if ui.button("Browse Existing...").clicked() {
|
let dialog_pending = world.resource::<NativeDialogBroker>().is_pending();
|
||||||
if let Some(root) = rfd::FileDialog::new().pick_folder() {
|
if ui
|
||||||
open_project_from_launcher(world, state, root);
|
.add_enabled(!dialog_pending, egui::Button::new("Browse Existing..."))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
let result = world.resource::<NativeDialogBroker>().request(
|
||||||
|
|| rfd::FileDialog::new().pick_folder(),
|
||||||
|
|world, root| {
|
||||||
|
let Some(root) = root else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
world.resource_scope(|world, mut state: Mut<ProjectLauncherUi>| {
|
||||||
|
open_project_from_launcher(world, &mut state, root);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Err(error) = result {
|
||||||
|
state.status = Some(format!("Project browser unavailable: {error}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,8 +256,23 @@ fn launcher_actions(world: &mut World, ui: &mut egui::Ui, state: &mut ProjectLau
|
|||||||
)
|
)
|
||||||
.truncate(),
|
.truncate(),
|
||||||
);
|
);
|
||||||
if ui.button("Choose Empty Folder...").clicked() {
|
if ui
|
||||||
state.sandbox_destination = rfd::FileDialog::new().pick_folder();
|
.add_enabled(!dialog_pending, egui::Button::new("Choose Empty Folder..."))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
let result = world.resource::<NativeDialogBroker>().request(
|
||||||
|
|| rfd::FileDialog::new().pick_folder(),
|
||||||
|
|world, destination| {
|
||||||
|
if let Some(destination) = destination {
|
||||||
|
world
|
||||||
|
.resource_mut::<ProjectLauncherUi>()
|
||||||
|
.sandbox_destination = Some(destination);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Err(error) = result {
|
||||||
|
state.status = Some(format!("Folder browser unavailable: {error}"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let can_create =
|
let can_create =
|
||||||
state.sandbox_destination.is_some() && !state.sandbox_name.trim().is_empty();
|
state.sandbox_destination.is_some() && !state.sandbox_name.trim().is_empty();
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
pub mod collaboration;
|
pub mod collaboration;
|
||||||
pub mod diagnostics_bundle;
|
pub mod diagnostics_bundle;
|
||||||
pub mod launcher;
|
pub mod launcher;
|
||||||
|
pub mod native_dialog;
|
||||||
pub mod project_io;
|
pub mod project_io;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod settings_ui;
|
pub mod settings_ui;
|
||||||
|
|||||||
171
crates/editor/src/project/native_dialog.rs
Normal file
171
crates/editor/src/project/native_dialog.rs
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
//! Non-blocking bridge between native dialogs and main-thread editor workflows.
|
||||||
|
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender};
|
||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicUsize, Ordering},
|
||||||
|
Arc, Mutex,
|
||||||
|
};
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
type MainThreadCompletion = Box<dyn FnOnce(&mut World) + Send + 'static>;
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct NativeDialogBroker {
|
||||||
|
sender: Sender<MainThreadCompletion>,
|
||||||
|
receiver: Mutex<Receiver<MainThreadCompletion>>,
|
||||||
|
pending: Arc<AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NativeDialogBroker {
|
||||||
|
fn default() -> Self {
|
||||||
|
let (sender, receiver) = mpsc::channel();
|
||||||
|
Self {
|
||||||
|
sender,
|
||||||
|
receiver: Mutex::new(receiver),
|
||||||
|
pending: Arc::new(AtomicUsize::new(0)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeDialogBroker {
|
||||||
|
pub fn is_pending(&self) -> bool {
|
||||||
|
self.pending.load(Ordering::Acquire) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request<T, D, C>(&self, dialog: D, completion: C) -> Result<(), &'static str>
|
||||||
|
where
|
||||||
|
T: Send + 'static,
|
||||||
|
D: FnOnce() -> T + Send + 'static,
|
||||||
|
C: FnOnce(&mut World, T) + Send + 'static,
|
||||||
|
{
|
||||||
|
if self
|
||||||
|
.pending
|
||||||
|
.compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Err("another native dialog is already open");
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender = self.sender.clone();
|
||||||
|
let pending = Arc::clone(&self.pending);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = dialog();
|
||||||
|
let callback: MainThreadCompletion = Box::new(move |world| completion(world, result));
|
||||||
|
if sender.send(callback).is_err() {
|
||||||
|
pending.store(0, Ordering::Release);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NativeDialogPlugin;
|
||||||
|
|
||||||
|
impl Plugin for NativeDialogPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<NativeDialogBroker>()
|
||||||
|
.add_systems(Update, drain_native_dialog_results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_native_dialog_results(world: &mut World) {
|
||||||
|
let callbacks: Vec<_> = {
|
||||||
|
let broker = world.resource::<NativeDialogBroker>();
|
||||||
|
broker
|
||||||
|
.receiver
|
||||||
|
.lock()
|
||||||
|
.expect("native dialog receiver poisoned")
|
||||||
|
.try_iter()
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for callback in callbacks {
|
||||||
|
world
|
||||||
|
.resource::<NativeDialogBroker>()
|
||||||
|
.pending
|
||||||
|
.store(0, Ordering::Release);
|
||||||
|
callback(world);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct ResultValue(Option<u32>);
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct FrameCount(u32);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_is_exclusive_and_completion_runs_once() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(NativeDialogPlugin)
|
||||||
|
.init_resource::<ResultValue>();
|
||||||
|
app.world()
|
||||||
|
.resource::<NativeDialogBroker>()
|
||||||
|
.request(
|
||||||
|
|| 42,
|
||||||
|
|world, value| world.resource_mut::<ResultValue>().0 = Some(value),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(app
|
||||||
|
.world()
|
||||||
|
.resource::<NativeDialogBroker>()
|
||||||
|
.request(|| 7, |_, _| {})
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
for _ in 0..100 {
|
||||||
|
app.update();
|
||||||
|
if app.world().resource::<ResultValue>().0.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
assert_eq!(app.world().resource::<ResultValue>().0, Some(42));
|
||||||
|
app.update();
|
||||||
|
assert_eq!(app.world().resource::<ResultValue>().0, Some(42));
|
||||||
|
assert!(!app.world().resource::<NativeDialogBroker>().is_pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocked_dialog_worker_does_not_block_app_frames() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(NativeDialogPlugin)
|
||||||
|
.init_resource::<ResultValue>()
|
||||||
|
.init_resource::<FrameCount>()
|
||||||
|
.add_systems(Update, |mut frames: ResMut<FrameCount>| frames.0 += 1);
|
||||||
|
let (release_sender, release_receiver) = mpsc::channel();
|
||||||
|
app.world()
|
||||||
|
.resource::<NativeDialogBroker>()
|
||||||
|
.request(
|
||||||
|
move || {
|
||||||
|
release_receiver
|
||||||
|
.recv()
|
||||||
|
.expect("test release sender dropped")
|
||||||
|
},
|
||||||
|
|world, value| world.resource_mut::<ResultValue>().0 = Some(value),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for _ in 0..8 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
assert_eq!(app.world().resource::<FrameCount>().0, 8);
|
||||||
|
assert_eq!(app.world().resource::<ResultValue>().0, None);
|
||||||
|
assert!(app.world().resource::<NativeDialogBroker>().is_pending());
|
||||||
|
|
||||||
|
release_sender.send(73).unwrap();
|
||||||
|
for _ in 0..100 {
|
||||||
|
app.update();
|
||||||
|
if app.world().resource::<ResultValue>().0.is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::thread::yield_now();
|
||||||
|
}
|
||||||
|
assert_eq!(app.world().resource::<ResultValue>().0, Some(73));
|
||||||
|
assert!(app.world().resource::<FrameCount>().0 > 8);
|
||||||
|
assert!(!app.world().resource::<NativeDialogBroker>().is_pending());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,6 +23,7 @@ use shared::{
|
|||||||
|
|
||||||
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
|
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
|
||||||
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
|
use crate::history::{clear_level_objects, snapshot_entity, EditorHistory};
|
||||||
|
use crate::native_dialog::NativeDialogBroker;
|
||||||
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
|
use crate::project::collaboration::{publish_authored_file, FileSnapshot, FileWriteIntent};
|
||||||
use crate::scene::recovery::{
|
use crate::scene::recovery::{
|
||||||
default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
|
default_state_root, discard_recovery_snapshots, latest_recovery_snapshot,
|
||||||
@ -82,8 +83,6 @@ pub struct SceneIo {
|
|||||||
pub request: Option<SceneIoRequest>,
|
pub request: Option<SceneIoRequest>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub dirty: bool,
|
pub dirty: bool,
|
||||||
/// When set, the next I/O request needs unsaved-changes confirmation.
|
|
||||||
pub pending_request: Option<SceneIoRequest>,
|
|
||||||
/// Newest recovery snapshot that is newer than the active authored scene.
|
/// Newest recovery snapshot that is newer than the active authored scene.
|
||||||
pub recovery_snapshot: Option<PathBuf>,
|
pub recovery_snapshot: Option<PathBuf>,
|
||||||
/// Bounded in-session audit trail for scene persistence and recovery operations.
|
/// Bounded in-session audit trail for scene persistence and recovery operations.
|
||||||
@ -119,7 +118,6 @@ impl Default for SceneIo {
|
|||||||
request: None,
|
request: None,
|
||||||
status: "Ready".to_string(),
|
status: "Ready".to_string(),
|
||||||
dirty: false,
|
dirty: false,
|
||||||
pending_request: None,
|
|
||||||
recovery_snapshot: None,
|
recovery_snapshot: None,
|
||||||
events: VecDeque::new(),
|
events: VecDeque::new(),
|
||||||
tabs: vec![SceneTab {
|
tabs: vec![SceneTab {
|
||||||
@ -291,39 +289,15 @@ fn load_startup_scene(world: &mut World) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn process_scene_io_requests(world: &mut World) {
|
fn process_scene_io_requests(world: &mut World) {
|
||||||
let request = {
|
let Some(request) = world.resource_mut::<SceneIo>().request.take() else {
|
||||||
let mut io = world.resource_mut::<SceneIo>();
|
return;
|
||||||
if let Some(pending) = io.pending_request.take() {
|
|
||||||
pending
|
|
||||||
} else {
|
|
||||||
match io.request.take() {
|
|
||||||
Some(req) if io.has_unsaved_tabs() && needs_dirty_confirm(&req) => {
|
|
||||||
io.pending_request = Some(req);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Some(req) => req,
|
|
||||||
None => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if world.resource::<SceneIo>().has_unsaved_tabs() && needs_dirty_confirm(&request) {
|
if matches!(request, SceneIoRequest::SwitchProject)
|
||||||
match confirm_discard_or_save() {
|
&& world.resource::<SceneIo>().has_unsaved_tabs()
|
||||||
DirtyConfirm::Save => {
|
{
|
||||||
let save_status = save_all_tabs(world);
|
request_switch_project_confirmation(world);
|
||||||
if save_status.starts_with("Save failed") || save_status.ends_with("cancelled") {
|
return;
|
||||||
world.resource_mut::<SceneIo>().set_status(save_status);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DirtyConfirm::Discard => {}
|
|
||||||
DirtyConfirm::Cancel => {
|
|
||||||
world
|
|
||||||
.resource_mut::<SceneIo>()
|
|
||||||
.set_status("Operation cancelled");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let status = match request {
|
let status = match request {
|
||||||
@ -348,16 +322,6 @@ fn process_scene_io_requests(world: &mut World) {
|
|||||||
world.resource_mut::<SceneIo>().set_status(status);
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DirtyConfirm {
|
|
||||||
Save,
|
|
||||||
Discard,
|
|
||||||
Cancel,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn needs_dirty_confirm(request: &SceneIoRequest) -> bool {
|
|
||||||
matches!(request, SceneIoRequest::SwitchProject)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn switch_project(world: &mut World) -> String {
|
fn switch_project(world: &mut World) -> String {
|
||||||
match crate::launcher::spawn_project_launcher_process() {
|
match crate::launcher::spawn_project_launcher_process() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@ -368,19 +332,41 @@ fn switch_project(world: &mut World) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm_discard_or_save() -> DirtyConfirm {
|
fn request_switch_project_confirmation(world: &mut World) {
|
||||||
use rfd::{MessageButtons, MessageDialog, MessageLevel};
|
let result = world.resource::<NativeDialogBroker>().request(
|
||||||
match MessageDialog::new()
|
|| {
|
||||||
.set_title("Unsaved Changes")
|
rfd::MessageDialog::new()
|
||||||
.set_description("The scene has unsaved changes. Save before continuing?")
|
.set_title("Unsaved Changes")
|
||||||
.set_level(MessageLevel::Warning)
|
.set_description("The scene has unsaved changes. Save before continuing?")
|
||||||
.set_buttons(MessageButtons::YesNoCancel)
|
.set_level(rfd::MessageLevel::Warning)
|
||||||
.show()
|
.set_buttons(rfd::MessageButtons::YesNoCancel)
|
||||||
{
|
.show()
|
||||||
rfd::MessageDialogResult::Yes => DirtyConfirm::Save,
|
},
|
||||||
rfd::MessageDialogResult::No => DirtyConfirm::Discard,
|
|world, decision| match decision {
|
||||||
_ => DirtyConfirm::Cancel,
|
rfd::MessageDialogResult::Yes => {
|
||||||
}
|
let status = save_all_tabs(world);
|
||||||
|
if status == "Saved all modified scene tabs" {
|
||||||
|
let status = switch_project(world);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
} else {
|
||||||
|
world.resource_mut::<SceneIo>().set_status(format!(
|
||||||
|
"Project switch paused until every scene is saved: {status}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rfd::MessageDialogResult::No => {
|
||||||
|
let status = switch_project(world);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
}
|
||||||
|
_ => world
|
||||||
|
.resource_mut::<SceneIo>()
|
||||||
|
.set_status("Project switch cancelled"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(match result {
|
||||||
|
Ok(()) => "Waiting for unsaved-scene confirmation".into(),
|
||||||
|
Err(error) => format!("Project switch unavailable: {error}"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_recent(world: &mut World, index: usize) -> String {
|
fn open_recent(world: &mut World, index: usize) -> String {
|
||||||
@ -533,18 +519,14 @@ fn close_scene_tab(world: &mut World, index: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if world.resource::<SceneIo>().dirty {
|
if world.resource::<SceneIo>().dirty {
|
||||||
match confirm_close_scene() {
|
request_close_scene_confirmation(world);
|
||||||
DirtyConfirm::Save => {
|
return "Waiting for close-scene confirmation".to_string();
|
||||||
let status = save_active_or_prompt(world);
|
|
||||||
if status.starts_with("Save failed") || status.ends_with("cancelled") {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DirtyConfirm::Discard => {}
|
|
||||||
DirtyConfirm::Cancel => return "Close scene cancelled".to_string(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finish_close_scene_tab(world)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_close_scene_tab(world: &mut World) -> String {
|
||||||
if world.resource::<SceneIo>().tabs.len() == 1 {
|
if world.resource::<SceneIo>().tabs.len() == 1 {
|
||||||
clear_scene_world(world);
|
clear_scene_world(world);
|
||||||
world.insert_resource(SceneComposition {
|
world.insert_resource(SceneComposition {
|
||||||
@ -618,18 +600,36 @@ fn reload_active_composition(world: &mut World) -> String {
|
|||||||
"Reloaded active scene composition".to_string()
|
"Reloaded active scene composition".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm_close_scene() -> DirtyConfirm {
|
fn request_close_scene_confirmation(world: &mut World) {
|
||||||
use rfd::{MessageButtons, MessageDialog, MessageLevel};
|
let result = world.resource::<NativeDialogBroker>().request(
|
||||||
match MessageDialog::new()
|
|| {
|
||||||
.set_title("Close Scene")
|
rfd::MessageDialog::new()
|
||||||
.set_description("This scene tab has unsaved changes. Save before closing it?")
|
.set_title("Close Scene")
|
||||||
.set_level(MessageLevel::Warning)
|
.set_description("This scene tab has unsaved changes. Save before closing it?")
|
||||||
.set_buttons(MessageButtons::YesNoCancel)
|
.set_level(rfd::MessageLevel::Warning)
|
||||||
.show()
|
.set_buttons(rfd::MessageButtons::YesNoCancel)
|
||||||
{
|
.show()
|
||||||
rfd::MessageDialogResult::Yes => DirtyConfirm::Save,
|
},
|
||||||
rfd::MessageDialogResult::No => DirtyConfirm::Discard,
|
|world, decision| {
|
||||||
_ => DirtyConfirm::Cancel,
|
let status = match decision {
|
||||||
|
rfd::MessageDialogResult::Yes => {
|
||||||
|
let save_status = save_active_or_prompt(world);
|
||||||
|
if save_status.starts_with("Saved ") {
|
||||||
|
finish_close_scene_tab(world)
|
||||||
|
} else {
|
||||||
|
format!("Close scene paused until it is saved: {save_status}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rfd::MessageDialogResult::No => finish_close_scene_tab(world),
|
||||||
|
_ => "Close scene cancelled".to_string(),
|
||||||
|
};
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Err(error) = result {
|
||||||
|
world
|
||||||
|
.resource_mut::<SceneIo>()
|
||||||
|
.set_status(format!("Close scene unavailable: {error}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -694,12 +694,31 @@ fn save_selection_as_prefab(world: &mut World) -> String {
|
|||||||
return "Select level objects to save as prefab".to_string();
|
return "Select level objects to save as prefab".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(path) = rfd::FileDialog::new()
|
let request = world.resource::<NativeDialogBroker>().request(
|
||||||
.set_directory("assets/prefabs")
|
|| {
|
||||||
.add_filter("Bevy prefab", &["scn.ron", "ron"])
|
rfd::FileDialog::new()
|
||||||
.set_file_name("prefab.scn.ron")
|
.set_directory("assets/prefabs")
|
||||||
.save_file()
|
.add_filter("Bevy prefab", &["scn.ron", "ron"])
|
||||||
else {
|
.set_file_name("prefab.scn.ron")
|
||||||
|
.save_file()
|
||||||
|
},
|
||||||
|
move |world, path| {
|
||||||
|
let status = finish_save_selection_as_prefab(world, selection, path);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match request {
|
||||||
|
Ok(()) => "Choose a destination for the prefab".to_string(),
|
||||||
|
Err(error) => format!("Save prefab unavailable: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_save_selection_as_prefab(
|
||||||
|
world: &mut World,
|
||||||
|
selection: Vec<Entity>,
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
) -> String {
|
||||||
|
let Some(path) = path else {
|
||||||
return "Save prefab cancelled".to_string();
|
return "Save prefab cancelled".to_string();
|
||||||
};
|
};
|
||||||
let expected = match FileSnapshot::capture(&path) {
|
let expected = match FileSnapshot::capture(&path) {
|
||||||
@ -859,12 +878,27 @@ fn save_active_or_prompt(world: &mut World) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn save_with_dialog(world: &mut World) -> String {
|
fn save_with_dialog(world: &mut World) -> String {
|
||||||
let Some(path) = rfd::FileDialog::new()
|
let request = world.resource::<NativeDialogBroker>().request(
|
||||||
.set_directory("assets/levels")
|
|| {
|
||||||
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
rfd::FileDialog::new()
|
||||||
.set_file_name("editor_scene.scn.ron")
|
.set_directory("assets/levels")
|
||||||
.save_file()
|
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
||||||
else {
|
.set_file_name("editor_scene.scn.ron")
|
||||||
|
.save_file()
|
||||||
|
},
|
||||||
|
|world, path| {
|
||||||
|
let status = finish_save_with_dialog(world, path);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match request {
|
||||||
|
Ok(()) => "Choose a scene destination".to_string(),
|
||||||
|
Err(error) => format!("Save unavailable: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_save_with_dialog(world: &mut World, path: Option<PathBuf>) -> String {
|
||||||
|
let Some(path) = path else {
|
||||||
return "Save cancelled".to_string();
|
return "Save cancelled".to_string();
|
||||||
};
|
};
|
||||||
let expected = match FileSnapshot::capture(&path) {
|
let expected = match FileSnapshot::capture(&path) {
|
||||||
@ -885,31 +919,51 @@ fn save_with_dialog(world: &mut World) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_with_dialog(world: &mut World) -> String {
|
fn open_with_dialog(world: &mut World) -> String {
|
||||||
let Some(path) = rfd::FileDialog::new()
|
let request = world.resource::<NativeDialogBroker>().request(
|
||||||
.set_directory("assets/levels")
|
|| {
|
||||||
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
rfd::FileDialog::new()
|
||||||
.pick_file()
|
.set_directory("assets/levels")
|
||||||
else {
|
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
||||||
return "Open cancelled".to_string();
|
.pick_file()
|
||||||
};
|
},
|
||||||
|
|world, path| {
|
||||||
open_path(world, path)
|
let status = path.map_or_else(
|
||||||
|
|| "Open cancelled".to_string(),
|
||||||
|
|path| open_path(world, path),
|
||||||
|
);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match request {
|
||||||
|
Ok(()) => "Choose a scene to open".to_string(),
|
||||||
|
Err(error) => format!("Open unavailable: {error}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn import_with_dialog(world: &mut World) -> String {
|
fn import_with_dialog(world: &mut World) -> String {
|
||||||
let Some(paths) = rfd::FileDialog::new()
|
let request = world.resource::<NativeDialogBroker>().request(
|
||||||
.add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS)
|
|| {
|
||||||
.pick_files()
|
rfd::FileDialog::new()
|
||||||
else {
|
.add_filter("Editor assets", IMPORTABLE_ASSET_EXTENSIONS)
|
||||||
return "Import cancelled".to_string();
|
.pick_files()
|
||||||
};
|
},
|
||||||
|
|world, paths| {
|
||||||
match import_external_assets(&paths) {
|
let status = match paths {
|
||||||
Ok(count) => {
|
None => "Import cancelled".to_string(),
|
||||||
world.resource_mut::<EditorAssets>().refresh();
|
Some(paths) => match import_external_assets(&paths) {
|
||||||
format!("Imported {count} asset(s)")
|
Ok(count) => {
|
||||||
}
|
world.resource_mut::<EditorAssets>().refresh();
|
||||||
Err(err) => format!("Import failed: {err}"),
|
format!("Imported {count} asset(s)")
|
||||||
|
}
|
||||||
|
Err(err) => format!("Import failed: {err}"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match request {
|
||||||
|
Ok(()) => "Choose assets to import".to_string(),
|
||||||
|
Err(error) => format!("Import unavailable: {error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -921,14 +975,32 @@ fn export_selection_with_dialog(world: &mut World) -> String {
|
|||||||
return "Selected entity is not an authored level object".to_string();
|
return "Selected entity is not an authored level object".to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(path) = rfd::FileDialog::new()
|
let request = world.resource::<NativeDialogBroker>().request(
|
||||||
.set_directory("assets/levels")
|
|| {
|
||||||
.add_filter("Bevy prefab", &["scn.ron", "ron"])
|
rfd::FileDialog::new()
|
||||||
.set_file_name("selection.scn.ron")
|
.set_directory("assets/levels")
|
||||||
.save_file()
|
.add_filter("Bevy prefab", &["scn.ron", "ron"])
|
||||||
else {
|
.set_file_name("selection.scn.ron")
|
||||||
|
.save_file()
|
||||||
|
},
|
||||||
|
move |world, path| {
|
||||||
|
let status = finish_export_selection(world, entity, path);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match request {
|
||||||
|
Ok(()) => "Choose an export destination".to_string(),
|
||||||
|
Err(error) => format!("Export unavailable: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_export_selection(world: &mut World, entity: Entity, path: Option<PathBuf>) -> String {
|
||||||
|
let Some(path) = path else {
|
||||||
return "Export cancelled".to_string();
|
return "Export cancelled".to_string();
|
||||||
};
|
};
|
||||||
|
if snapshot_entity(world, entity).is_none() {
|
||||||
|
return "Export failed: the initiating selection no longer exists".to_string();
|
||||||
|
}
|
||||||
let expected = match FileSnapshot::capture(&path) {
|
let expected = match FileSnapshot::capture(&path) {
|
||||||
Ok(expected) => expected,
|
Ok(expected) => expected,
|
||||||
Err(error) => return format!("Export failed: {error}"),
|
Err(error) => return format!("Export failed: {error}"),
|
||||||
@ -1607,12 +1679,32 @@ fn save_recovery_copy_with_dialog(world: &mut World) -> String {
|
|||||||
|| "recovered_scene.scn.ron".to_string(),
|
|| "recovered_scene.scn.ron".to_string(),
|
||||||
|stem| format!("{stem}.recovered.scn.ron"),
|
|stem| format!("{stem}.recovered.scn.ron"),
|
||||||
);
|
);
|
||||||
let Some(destination) = rfd::FileDialog::new()
|
let directory = directory.to_path_buf();
|
||||||
.set_directory(directory)
|
let request = world.resource::<NativeDialogBroker>().request(
|
||||||
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
move || {
|
||||||
.set_file_name(file_name)
|
rfd::FileDialog::new()
|
||||||
.save_file()
|
.set_directory(directory)
|
||||||
else {
|
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
||||||
|
.set_file_name(file_name)
|
||||||
|
.save_file()
|
||||||
|
},
|
||||||
|
move |world, destination| {
|
||||||
|
let status = finish_save_recovery_copy(world, snapshot, destination);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(status);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match request {
|
||||||
|
Ok(()) => "Choose a destination for the recovery copy".to_string(),
|
||||||
|
Err(error) => format!("Save recovery copy unavailable: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_save_recovery_copy(
|
||||||
|
world: &mut World,
|
||||||
|
snapshot: PathBuf,
|
||||||
|
destination: Option<PathBuf>,
|
||||||
|
) -> String {
|
||||||
|
let Some(destination) = destination else {
|
||||||
return "Save recovery copy cancelled".to_string();
|
return "Save recovery copy cancelled".to_string();
|
||||||
};
|
};
|
||||||
let expected = match FileSnapshot::capture(&destination) {
|
let expected = match FileSnapshot::capture(&destination) {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ use bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities;
|
|||||||
use egui_phosphor_icons::icons;
|
use egui_phosphor_icons::icons;
|
||||||
use shared::{SceneComposition, SubsceneReference};
|
use shared::{SceneComposition, SubsceneReference};
|
||||||
|
|
||||||
|
use crate::native_dialog::NativeDialogBroker;
|
||||||
use crate::scene_io::{ComposedSceneMember, SceneIo, SceneIoRequest};
|
use crate::scene_io::{ComposedSceneMember, SceneIo, SceneIoRequest};
|
||||||
use crate::selection::SelectedEntity;
|
use crate::selection::SelectedEntity;
|
||||||
|
|
||||||
@ -195,19 +196,47 @@ fn composition_menu(
|
|||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
if ui
|
if ui
|
||||||
.add_enabled(editing, egui::Button::new("Add Subscene..."))
|
.add_enabled(
|
||||||
|
editing && !world.resource::<NativeDialogBroker>().is_pending(),
|
||||||
|
egui::Button::new("Add Subscene..."),
|
||||||
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
match choose_subscene(world, &composition) {
|
let project_root =
|
||||||
Ok(Some(reference)) => {
|
PathBuf::from(&world.resource::<crate::project_io::ProjectWorkspace>().root);
|
||||||
composition.subscenes.push(reference);
|
let directory = project_root.join("assets/levels");
|
||||||
changed = true;
|
let result = world.resource::<NativeDialogBroker>().request(
|
||||||
}
|
move || {
|
||||||
Ok(None) => {}
|
rfd::FileDialog::new()
|
||||||
Err(error) => world
|
.set_directory(directory)
|
||||||
.resource_mut::<SceneIo>()
|
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
||||||
.set_status(format!("Add subscene failed: {error}")),
|
.pick_file()
|
||||||
}
|
},
|
||||||
|
move |world, path| {
|
||||||
|
let Some(path) = path else {
|
||||||
|
world
|
||||||
|
.resource_mut::<SceneIo>()
|
||||||
|
.set_status("Add subscene cancelled");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let current = world.resource::<SceneComposition>().clone();
|
||||||
|
match validate_subscene_choice(&project_root, ¤t, path) {
|
||||||
|
Ok(reference) => {
|
||||||
|
let mut updated = current;
|
||||||
|
updated.subscenes.push(reference);
|
||||||
|
crate::history::set_scene_composition_with_history(world, updated);
|
||||||
|
world.resource_mut::<SceneIo>().set_status("Subscene added");
|
||||||
|
}
|
||||||
|
Err(error) => world
|
||||||
|
.resource_mut::<SceneIo>()
|
||||||
|
.set_status(format!("Add subscene failed: {error}")),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
world.resource_mut::<SceneIo>().set_status(match result {
|
||||||
|
Ok(()) => "Choose a scene to compose".into(),
|
||||||
|
Err(error) => format!("Add subscene unavailable: {error}"),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
@ -218,20 +247,14 @@ fn composition_menu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn choose_subscene(
|
fn validate_subscene_choice(
|
||||||
world: &World,
|
project_root: &Path,
|
||||||
composition: &SceneComposition,
|
composition: &SceneComposition,
|
||||||
) -> Result<Option<SubsceneReference>, String> {
|
path: PathBuf,
|
||||||
let project_root = PathBuf::from(&world.resource::<crate::project_io::ProjectWorkspace>().root)
|
) -> Result<SubsceneReference, String> {
|
||||||
|
let project_root = project_root
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.map_err(|error| format!("could not resolve project root: {error}"))?;
|
.map_err(|error| format!("could not resolve project root: {error}"))?;
|
||||||
let Some(path) = rfd::FileDialog::new()
|
|
||||||
.set_directory(project_root.join("assets/levels"))
|
|
||||||
.add_filter("Bevy scene", &["scn.ron", "ron"])
|
|
||||||
.pick_file()
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let path = path
|
let path = path
|
||||||
.canonicalize()
|
.canonicalize()
|
||||||
.map_err(|error| format!("could not resolve {}: {error}", path.display()))?;
|
.map_err(|error| format!("could not resolve {}: {error}", path.display()))?;
|
||||||
@ -251,12 +274,12 @@ fn choose_subscene(
|
|||||||
{
|
{
|
||||||
return Err(format!("{relative} is already composed"));
|
return Err(format!("{relative} is already composed"));
|
||||||
}
|
}
|
||||||
Ok(Some(SubsceneReference {
|
Ok(SubsceneReference {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
path: relative,
|
path: relative,
|
||||||
visible: true,
|
visible: true,
|
||||||
locked: true,
|
locked: true,
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn focus_subscene(world: &mut World, selected: &mut SelectedEntities, reference_id: &str) {
|
fn focus_subscene(world: &mut World, selected: &mut SelectedEntities, reference_id: &str) {
|
||||||
|
|||||||
@ -52,6 +52,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
|||||||
| [0035](adr/0035-shared-material-assets-and-renderer-slots.md) | Shared Material/Material Instance assets, stable renderer slots, and runtime-only property blocks |
|
| [0035](adr/0035-shared-material-assets-and-renderer-slots.md) | Shared Material/Material Instance assets, stable renderer slots, and runtime-only property blocks |
|
||||||
| [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary |
|
| [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary |
|
||||||
| [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers |
|
| [0037](adr/0037-collaborative-authored-file-safety.md) | Exact authored-file revisions, observational Git status, and optional ownership providers |
|
||||||
|
| [0038](adr/0038-non-blocking-native-dialog-broker.md) | Worker-owned native waits with one-shot main-thread workflow completion |
|
||||||
|
|
||||||
## Editor framework
|
## Editor framework
|
||||||
|
|
||||||
@ -77,6 +78,7 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
|||||||
| [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract |
|
| [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract |
|
||||||
| [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics |
|
| [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics |
|
||||||
| [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers |
|
| [editor/collaborative-file-safety.md](editor/collaborative-file-safety.md) | Guarded authored writes, compact Git/read-only status, conflict recovery, and ownership providers |
|
||||||
|
| [editor/native-dialogs.md](editor/native-dialogs.md) | Non-blocking native dialog acquisition and main-thread result application |
|
||||||
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
|
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
|
||||||
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
||||||
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
||||||
|
|||||||
40
docs/adr/0038-non-blocking-native-dialog-broker.md
Normal file
40
docs/adr/0038-non-blocking-native-dialog-broker.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# ADR 0038: Non-Blocking Native Dialog Broker
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Blacksite opens native file, folder, Save As, and confirmation dialogs from editor workflows. Calling
|
||||||
|
the synchronous `rfd` API inside a Bevy/egui system stops window event processing until the dialog
|
||||||
|
returns. On Wayland compositors this can trigger an application-not-responding prompt even though
|
||||||
|
the native dialog is operating normally.
|
||||||
|
|
||||||
|
Path selection precedes stateful main-thread work such as scene serialization, imports, history
|
||||||
|
changes, prefab relinking, collaborative revision checks, and project activation. Moving those
|
||||||
|
operations to arbitrary worker threads would violate ECS ownership and weaken existing guards.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The editor owns one `NativeDialogBroker` resource. A workflow submits native dialog construction and
|
||||||
|
a typed completion callback. The broker runs only the blocking native dialog wait on a worker thread,
|
||||||
|
then queues the result. An exclusive Bevy system drains the queue and invokes each completion exactly
|
||||||
|
once with `&mut World`.
|
||||||
|
|
||||||
|
Only one native dialog may be active. Additional requests fail immediately with explicit status.
|
||||||
|
Cancellation is delivered as the dialog API's empty or cancel result and remains non-mutating.
|
||||||
|
Initiating workflows capture stable entity IDs or paths and revalidate them before applying a result.
|
||||||
|
|
||||||
|
Native confirmation dialogs use the same broker. A requested close or project switch proceeds only
|
||||||
|
after the confirmation result and any required save have completed; an incomplete asynchronous save
|
||||||
|
pauses the transition rather than discarding content.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- Blacksite continues rendering and answering compositor pings while native dialogs are open.
|
||||||
|
- Project and ECS mutations remain on the Bevy main thread with their existing history, validation,
|
||||||
|
and collaborative file-revision boundaries.
|
||||||
|
- Workflows split dialog acquisition from result application and handle stale initiating state.
|
||||||
|
- The one-dialog policy is intentionally global across the editor and Project Browser.
|
||||||
|
- A future platform-specific async backend can replace the worker without changing completions.
|
||||||
@ -24,9 +24,11 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
|||||||
| [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history |
|
| [extensibility.md](extensibility.md) | Static authoring component lifecycle registration, stable IDs, composition, and generic history |
|
||||||
| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration |
|
| [material-system.md](material-system.md) | Shared Material/Material Instance assets, static/skinned renderer slots, Surface ABI, Solari scope, and migration |
|
||||||
| [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers |
|
| [collaborative-file-safety.md](collaborative-file-safety.md) | Exact authored-file revisions, Git/read-only status, conflict recovery, and optional ownership providers |
|
||||||
|
| [native-dialogs.md](native-dialogs.md) | Non-blocking file/folder/confirmation acquisition and main-thread result application |
|
||||||
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
|
| [evaluations/material-renderer-foundation/](evaluations/material-renderer-foundation/) | Live screenshots and verification record for the renderer/material/component foundation |
|
||||||
| [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops |
|
| [evaluations/material-library-targeted-drop/](evaluations/material-library-targeted-drop/) | Live screenshot and verification record for the docked Material Library and exact reversible surface drops |
|
||||||
| [evaluations/collaborative-file-safety/](evaluations/collaborative-file-safety/) | Live screenshot and verification record for source-control status and guarded external-change recovery |
|
| [evaluations/collaborative-file-safety/](evaluations/collaborative-file-safety/) | Live screenshot and verification record for source-control status and guarded external-change recovery |
|
||||||
|
| [evaluations/native-dialog-responsiveness/](evaluations/native-dialog-responsiveness/) | Live native-Wayland screenshot and verification record for non-blocking picker responsiveness |
|
||||||
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
||||||
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
||||||
|
|
||||||
@ -48,6 +50,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
|||||||
| `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 |
|
| `shared::prefab_overrides` | Versioned stable override schema and editor-independent runtime application | prefab-authoring.md, ADR 0027 |
|
||||||
| `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 |
|
| `project/` | Workspace, settings UI, user prefs, support diagnostics | roadmap Phase 1 |
|
||||||
| `project/collaboration.rs` | Guarded authored writes, asynchronous Git status, and optional ownership providers | collaborative-file-safety.md, ADR 0037 |
|
| `project/collaboration.rs` | Guarded authored writes, asynchronous Git status, and optional ownership providers | collaborative-file-safety.md, ADR 0037 |
|
||||||
|
| `project/native_dialog.rs` | One-at-a-time native dialog worker and one-shot main-thread completions | native-dialogs.md, ADR 0038 |
|
||||||
| `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 |
|
| `project/session.rs` | Versioned XDG session document, clean marker, and safe resume | session-recovery.md, ADR 0024 |
|
||||||
| `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 |
|
| `project/diagnostics_bundle.rs` | Privacy-bounded transactional support report export | session-recovery.md, ADR 0024 |
|
||||||
| `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 |
|
| `project/launcher.rs` | Strict project inspection, CLI activation, recent filtering, and sandbox scaffolding | project-launcher.md, ADR 0025 |
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
# Native Dialog Responsiveness Evaluation
|
||||||
|
|
||||||
|
Date: 2026-07-12
|
||||||
|
Branch: `codex/non-blocking-native-dialogs`
|
||||||
|
Implementation commits: `d038cf3` and the closing commit for Gitea #52
|
||||||
|
|
||||||
|
This record captures live acceptance for the non-blocking native-dialog broker. The permanent
|
||||||
|
workflow contract lives in the [native-dialog guide](../../native-dialogs.md).
|
||||||
|
|
||||||
|
## Live Editor Evidence
|
||||||
|
|
||||||
|
The native KDE portal Open dialog below was launched through the production `SceneIoRequest::Open`
|
||||||
|
path in a native Wayland debug editor and held open for 12 seconds.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
During the hold, Hyprland continued to report Blacksite as mapped, visible, input-capable, and native
|
||||||
|
Wayland. No application-not-responding client appeared. Cancel closed the picker without changing the
|
||||||
|
active scene, and the editor then stopped cleanly.
|
||||||
|
|
||||||
|
## Acceptance Results
|
||||||
|
|
||||||
|
| Area | Result | Evidence |
|
||||||
|
|------|--------|----------|
|
||||||
|
| Frame responsiveness | Pass | A blocked-dialog regression test advances eight Bevy frames before releasing the worker; live editor remained compositor-responsive during the 12-second picker hold |
|
||||||
|
| Shared coverage | Pass | Every `rfd::FileDialog` and `rfd::MessageDialog` construction is inside a `NativeDialogBroker::request` worker closure |
|
||||||
|
| Request exclusivity | Pass | Focused test rejects a second request while one is pending and permits work after one-shot completion |
|
||||||
|
| Cancellation | Pass | Live Open cancellation preserved the active scene; workflow callbacks retain explicit non-mutating cancel branches |
|
||||||
|
| Main-thread application | Pass | Paths/results cross the broker, while scene, asset, history, registry, project, and collaboration work remains in `&mut World` completions |
|
||||||
|
| Stale initiating state | Pass | Prefab/export completions reject missing initiating entities; composition validates against current state after selection |
|
||||||
|
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed here |
|
||||||
|
|
||||||
|
## Automated Verification
|
||||||
|
|
||||||
|
| Command/suite | Result |
|
||||||
|
|---------------|--------|
|
||||||
|
| `cargo fmt --all -- --check` | Pass |
|
||||||
|
| `cargo check --workspace --all-targets` | Pass |
|
||||||
|
| `cargo clippy --workspace --all-targets -- -D warnings` | Pass |
|
||||||
|
| `cargo test --workspace` | Pass |
|
||||||
|
| Focused native-dialog lifecycle and frame-progress tests | 2 passed |
|
||||||
|
| `git diff --check` | Pass |
|
||||||
BIN
docs/editor/evaluations/native-dialog-responsiveness/native-open-dialog.png
(Stored with Git LFS)
Normal file
BIN
docs/editor/evaluations/native-dialog-responsiveness/native-open-dialog.png
(Stored with Git LFS)
Normal file
Binary file not shown.
29
docs/editor/native-dialogs.md
Normal file
29
docs/editor/native-dialogs.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Native Dialog Workflows
|
||||||
|
|
||||||
|
Blacksite routes native file, folder, Save As, and unsaved-change confirmation dialogs through the
|
||||||
|
shared `NativeDialogBroker`. A native dialog may remain open without blocking Bevy's window event
|
||||||
|
loop or editor rendering.
|
||||||
|
|
||||||
|
## Workflow Contract
|
||||||
|
|
||||||
|
- One native dialog can be active at a time. Direct controls are disabled while pending; other
|
||||||
|
concurrent requests report that a dialog is already open.
|
||||||
|
- Native work returns only selected paths or a confirmation result. Scene, asset, project, history,
|
||||||
|
registry, and collaborative-file mutations run later on the Bevy main thread.
|
||||||
|
- Cancel is non-mutating and produces workflow-specific status.
|
||||||
|
- Entity-based operations capture the initiating entity and reject stale results. Composition
|
||||||
|
additions validate against the current composition so intervening edits are not overwritten.
|
||||||
|
- Collaborative Save As performs the same revision and destination checks after selection.
|
||||||
|
- Close or project switch never treats an opened Save As picker as a completed save. When a save
|
||||||
|
remains pending, the transition pauses and can be retried after saving.
|
||||||
|
|
||||||
|
## Covered Surfaces
|
||||||
|
|
||||||
|
The broker owns scene Open/Save As, import/export, prefab creation and relinking, recovery copies,
|
||||||
|
subscene selection, collaborative conflict copies, Project Browser folder selection, and dirty-scene
|
||||||
|
confirmations. New workflows must use the broker instead of calling `rfd` from an egui/Bevy system.
|
||||||
|
|
||||||
|
See [ADR 0038](../adr/0038-non-blocking-native-dialog-broker.md).
|
||||||
|
|
||||||
|
Live native-Wayland acceptance is recorded in the
|
||||||
|
[native-dialog responsiveness evaluation](evaluations/native-dialog-responsiveness/).
|
||||||
Loading…
Reference in New Issue
Block a user