Compare commits
No commits in common. "a048233fceab86649f8c1004137a59f0cd62ac12" and "62b538999d436a55b21596fb00912b2e69a77d6f" have entirely different histories.
a048233fce
...
62b538999d
@ -1,62 +0,0 @@
|
||||
# Guarded Shutdown And History Savepoints
|
||||
|
||||
Date: 2026-07-13
|
||||
Issue: BS-PR-709 / Gitea #55
|
||||
Milestone: M7 - Production Readiness
|
||||
|
||||
## Goal
|
||||
|
||||
Route every editor-exit surface through one non-blocking dirty-document decision and make scene
|
||||
dirtiness follow the authored undo timeline. Closing the native window, choosing File > Quit, and
|
||||
requesting an editor exit programmatically must never silently discard dirty scene tabs.
|
||||
|
||||
## Decisions
|
||||
|
||||
- The editor disables Bevy's automatic close-and-exit systems and owns primary-window close
|
||||
requests. A guarded-shutdown resource is the single authority that may emit `AppExit`.
|
||||
- The final dirty-state recheck and `AppExit` authorization run in a dedicated schedule after
|
||||
Bevy's `Last`; session clean-marker persistence runs after that finalizer.
|
||||
- Native window close, File > Quit, and internal editor quit requests enqueue the same shutdown
|
||||
intent. Repeated requests while confirmation or Save As is pending are coalesced.
|
||||
- Clean sessions exit immediately. Dirty sessions use the shared `NativeDialogBroker` and a single
|
||||
Save All / Discard / Cancel decision. Cancel preserves all tabs and live editor state; Discard
|
||||
exits without rewriting authored files; Save All exits only after every dirty tab is saved.
|
||||
- Untitled tabs and other asynchronous Save As work keep shutdown pending until their dialog result
|
||||
is applied on the main thread. Cancellation or any failed write returns the editor to an idle,
|
||||
dirty, recoverable state and does not emit `AppExit`.
|
||||
- Each scene tab retains a canonical authored-content checkpoint established only by a successful
|
||||
load or save. The checkpoint is keyed by stable actor identity, canonical component order, stable
|
||||
parent identity, and scene composition rather than transient Bevy entity numbers.
|
||||
- Push marks the active document dirty. Undo and redo serialize the authoritative authored
|
||||
projection and compare it with that tab's checkpoint, so saving at nonzero history depth,
|
||||
returning to that point, branching, tab switches, and direct non-history mutations remain exact.
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Add per-tab canonical clean checkpoints to scene I/O and reconcile `SceneIo::dirty` after push,
|
||||
undo, redo, save, load, and document switches without persisting Entity-ID-based history stacks.
|
||||
2. Add focused history tests for undo-to-clean, redo-away-from-clean, save at nonzero depth,
|
||||
branching before/after the clean point, and tab isolation.
|
||||
3. Add a guarded-shutdown plugin/resource that intercepts `WindowCloseRequested`, accepts menu and
|
||||
programmatic requests, coordinates non-blocking confirmation/save completion, and emits the only
|
||||
final `AppExit` for the full editor.
|
||||
4. Make Save All report complete, pending, cancelled, or failed explicitly. Resume pending shutdown
|
||||
after untitled Save As completion and preserve the originating tab plus dirty state on failure.
|
||||
5. Add File > Quit and route Switch Project's exit half through the same guard without spawning a
|
||||
replacement process until the dirty-document decision succeeds.
|
||||
6. Update ADR 0023, native-dialog/session-recovery documentation, editor architecture guidance,
|
||||
root README controls/checklist, and production-readiness evidence.
|
||||
7. Run formatting, strict Clippy, workspace tests, and focused headless state-machine tests. Keep
|
||||
packaged tests deferred by project-owner direction.
|
||||
8. Launch the exact editor commit under Hyprland. Exercise Cancel, Discard, Save All, clean close,
|
||||
File > Quit, and the compositor close button; verify the editor remains responsive during native
|
||||
dialogs, no state is lost on cancel/failure, and no process or warning remains after exit.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Native close, File > Quit, and programmatic editor exit share one guarded implementation.
|
||||
- One or many dirty tabs cannot be lost without explicit Discard; failed or cancelled saves do not
|
||||
exit and preserve the session.
|
||||
- Undoing exactly to the saved state clears the dirty marker; redo or a divergent edit restores it.
|
||||
- Saving at nonzero history depth establishes a new clean point without deleting useful history.
|
||||
- Headless state-machine tests and exact-commit native Linux QA pass with clean logs.
|
||||
12
README.md
12
README.md
@ -57,9 +57,8 @@ cargo run -p editor --bin project_launcher --features dev
|
||||
```
|
||||
|
||||
The installed **Blacksite Editor** desktop entry also exposes **Open Project Browser** from its
|
||||
desktop action menu. In the editor, **File > Switch Project...** uses the guarded Save All / Discard /
|
||||
Cancel shutdown path before opening the same browser; choosing a project starts a fresh editor
|
||||
process with that root.
|
||||
desktop action menu. In the editor, **File > Switch Project...** performs a clean shutdown and
|
||||
opens the same browser; choosing a project starts a fresh editor process with that root.
|
||||
|
||||
### Hot reload (gameplay iteration)
|
||||
|
||||
@ -137,7 +136,7 @@ deep-stale variants.
|
||||
| Click empty viewport / `Esc` | Deselect |
|
||||
| `Delete` / `Backspace` | Delete selection |
|
||||
| `Ctrl+D` | Duplicate selection |
|
||||
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo; returning exactly to the last loaded or saved authored state clears that scene tab's dirty marker |
|
||||
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo |
|
||||
| `F2` in Hierarchy | Rename selection |
|
||||
| `W` / `E` / `R` | Translate / rotate / scale gizmo; multi-selection uses one grouped gizmo and undo step |
|
||||
| `X` | Toggle world/local gizmo orientation |
|
||||
@ -166,8 +165,7 @@ deep-stale variants.
|
||||
| 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 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, non-blocking native Open/Save As, **Open Sample** for the five-area regression pack, recovery restore/keep-copy/discard when available, Import Assets, Export Selection, Save Selection As Prefab (including linked-root variants), Recent Scenes, Switch Project, and Quit |
|
||||
| Window close / **File > Quit** | Clean sessions exit immediately; dirty scene tabs use one non-blocking **Save All / Discard / Cancel** decision, and cancel or failed saves keep the editor open |
|
||||
| File menu | New, non-blocking native Open/Save As, **Open Sample** for the five-area regression pack, 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 |
|
||||
| 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 |
|
||||
@ -412,8 +410,6 @@ crates/
|
||||
- [x] Transactional physics placement with real Avian gravity/colliders, paused Edit-mode physics, prerequisite diagnostics, isolated non-selected bodies, exact cancel, and grouped transform undo ([ADR 0041](docs/adr/0041-transactional-editor-physics-placement.md), [workflow guide](docs/editor/physics-placement.md), [Gitea #25](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/25))
|
||||
- [x] Collider authoring health shared by the inspector, Collider viewport, Diagnostics panel, and physics placement, with scaled shape overlays, cooked mesh bounds, missing/stale/invalid/oversized findings, and undoable dimension-preserving shape switching ([collider guide](docs/editor/collider-authoring.md), [Gitea #26](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/26))
|
||||
- [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] Guarded native/menu/programmatic editor shutdown with asynchronous multi-tab Save All / Discard / Cancel and canonical per-tab clean checkpoints for exact undo/redo dirtiness ([ADR 0042](docs/adr/0042-guarded-editor-shutdown-and-document-savepoints.md), [Gitea #55](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/55))
|
||||
- [x] Scoped egui action safety for toolbar/navigation spawns, Inspector state, cross-panel selection/tab requests, diagnostics selection, and full-slice selection precedence ([evaluation](docs/editor/evaluations/scoped-ui-actions/), [Gitea #57](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/57))
|
||||
- [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] Unified viewport render-to-texture target + Play session bootstrap
|
||||
|
||||
@ -78,12 +78,7 @@ impl EditorCommandRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom inspector body for level objects (game crates register via
|
||||
/// [`register_actor_inspector_section`]). Synchronous [`Self::ui`] callbacks run while
|
||||
/// [`UiState`] is scoped out of `World`; implementations must not request that resource. Keep
|
||||
/// extension-owned transient state in independently registered resources, use
|
||||
/// [`crate::ui::request_ui_selection`] and [`crate::ui::request_editor_tab`] for host UI changes,
|
||||
/// or defer other cross-panel work through extension-owned commands or events.
|
||||
/// Custom inspector body for level objects (game crates register via [`register_actor_inspector_section`]).
|
||||
pub trait ActorInspectorSection: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn title(&self) -> &str;
|
||||
|
||||
@ -19,6 +19,7 @@ use shared::{
|
||||
|
||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::state::scene_tools_active;
|
||||
use crate::ui::hierarchy_ops::{
|
||||
apply_sibling_change, apply_sibling_change_new, hierarchy_drop_violation, next_sibling_index,
|
||||
@ -1630,7 +1631,7 @@ pub fn apply_command_undo(world: &mut World) {
|
||||
undo_command(world, &mut command);
|
||||
world.resource_mut::<EditorHistory>().push_redo(command);
|
||||
world.resource_mut::<EditorHistory>().set_redo_status(label);
|
||||
crate::scene_io::reconcile_active_dirty_with_checkpoint(world);
|
||||
mark_dirty(world);
|
||||
}
|
||||
|
||||
pub fn apply_command_redo(world: &mut World) {
|
||||
@ -1651,7 +1652,7 @@ pub fn apply_command_redo(world: &mut World) {
|
||||
redo_command(world, &mut command);
|
||||
world.resource_mut::<EditorHistory>().push_undo(command);
|
||||
world.resource_mut::<EditorHistory>().set_undo_status(label);
|
||||
crate::scene_io::reconcile_active_dirty_with_checkpoint(world);
|
||||
mark_dirty(world);
|
||||
}
|
||||
|
||||
fn prepare_prefab_source_history(
|
||||
@ -2871,15 +2872,32 @@ fn despawn_entity(world: &mut World, entity: Option<Entity>) {
|
||||
}
|
||||
|
||||
fn select_one(world: &mut World, entity: Entity) {
|
||||
crate::ui::request_ui_selection(world, &[entity]);
|
||||
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
|
||||
ui_state.selected_entities.clear();
|
||||
ui_state.selected_entities.select_replace(entity);
|
||||
}
|
||||
world.resource_mut::<SelectedEntity>().0 = Some(entity);
|
||||
}
|
||||
|
||||
fn select_many(world: &mut World, entities: &[Entity]) {
|
||||
crate::ui::request_ui_selection(world, entities);
|
||||
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
|
||||
ui_state.selected_entities.clear();
|
||||
for (index, entity) in entities.iter().enumerate() {
|
||||
if index == 0 {
|
||||
ui_state.selected_entities.select_replace(*entity);
|
||||
} else {
|
||||
ui_state.selected_entities.select_maybe_add(*entity, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
world.resource_mut::<SelectedEntity>().0 = entities.first().copied();
|
||||
}
|
||||
|
||||
pub fn clear_selection(world: &mut World) {
|
||||
crate::ui::request_ui_selection(world, &[]);
|
||||
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
|
||||
ui_state.selected_entities.clear();
|
||||
}
|
||||
world.resource_mut::<SelectedEntity>().0 = None;
|
||||
}
|
||||
|
||||
pub fn is_level_object(world: &World, entity: Entity) -> bool {
|
||||
@ -2921,28 +2939,6 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||
use crate::operators::{ActiveOperator, OperatorPhase};
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::ui::PendingUiSelection;
|
||||
|
||||
#[test]
|
||||
fn history_select_many_queues_exact_selection_while_ui_state_is_scoped_out() {
|
||||
let first = Entity::from_bits(1);
|
||||
let second = Entity::from_bits(2);
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SelectedEntity>();
|
||||
world.init_resource::<PendingUiSelection>();
|
||||
world.insert_resource(UiState::default_layout());
|
||||
|
||||
world.resource_scope::<UiState, _>(|world, _ui_state| {
|
||||
assert!(!world.contains_resource::<UiState>());
|
||||
select_many(world, &[first, second]);
|
||||
assert_eq!(
|
||||
world.resource::<PendingUiSelection>().0.as_deref(),
|
||||
Some([first, second].as_slice())
|
||||
);
|
||||
assert_eq!(world.resource::<SelectedEntity>().0, Some(first));
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq)]
|
||||
#[reflect(Component, Default)]
|
||||
|
||||
@ -30,7 +30,6 @@ pub use project::project_io;
|
||||
pub use project::samples;
|
||||
pub use project::session;
|
||||
pub use project::settings_ui;
|
||||
pub use project::shutdown;
|
||||
pub use scene::scene_io;
|
||||
pub use scene::scene_schema;
|
||||
pub use scene::scene_view;
|
||||
@ -77,7 +76,6 @@ use play::PlaySessionPlugin;
|
||||
use project::collaboration::CollaborationPlugin;
|
||||
use project::native_dialog::NativeDialogPlugin;
|
||||
use project::samples::SampleCatalogPlugin;
|
||||
use project::shutdown::ShutdownPlugin;
|
||||
use project_io::ProjectIoPlugin;
|
||||
use render_view::RenderViewPlugin;
|
||||
use scene_io::SceneIoPlugin;
|
||||
@ -104,7 +102,6 @@ impl PluginGroup for EditorPluginGroup {
|
||||
.add(ProjectIoPlugin)
|
||||
.add(SampleCatalogPlugin)
|
||||
.add(NativeDialogPlugin)
|
||||
.add(ShutdownPlugin)
|
||||
.add(scene_schema::SceneSchemaPlugin)
|
||||
.add(net_editor::NetEditorPlugin)
|
||||
.add(AssetDbPlugin)
|
||||
@ -146,7 +143,7 @@ impl PluginGroup for EditorPluginGroup {
|
||||
|
||||
/// Shared Bevy app wiring for the in-process editor (game sim + egui shell).
|
||||
pub fn configure_editor_app(app: &mut App) {
|
||||
app.add_plugins(launch::editor_plugins("Bevy FPS Editor"))
|
||||
app.add_plugins(launch::default_plugins("Bevy FPS Editor"))
|
||||
.insert_resource(GameInputEnabled(false))
|
||||
.insert_resource(GameInputFocused(false))
|
||||
.insert_resource(SimEnabled(false))
|
||||
|
||||
@ -8,4 +8,3 @@ pub mod project_io;
|
||||
pub mod samples;
|
||||
pub mod session;
|
||||
pub mod settings_ui;
|
||||
pub mod shutdown;
|
||||
|
||||
@ -11,7 +11,6 @@ use crate::project_io::{ProjectWorkspace, UserPreferences};
|
||||
use crate::scene::recovery::{atomic_write, default_state_root};
|
||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||
use crate::settings_ui::ProjectSettingsPanel;
|
||||
use crate::shutdown::ShutdownCoordinator;
|
||||
use crate::ui::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
||||
use crate::viewport::rendering_diagnostics::RenderingDiagnosticsPanel;
|
||||
use crate::viewport::CameraBookmarks;
|
||||
@ -110,8 +109,8 @@ impl Plugin for EditorSessionPlugin {
|
||||
.add_systems(Startup, initialize_session)
|
||||
.add_systems(Update, persist_running_session)
|
||||
.add_systems(
|
||||
crate::shutdown::EditorShutdownFinalize,
|
||||
persist_clean_session_on_exit.after(crate::shutdown::finalize_authorized_shutdown),
|
||||
Last,
|
||||
persist_clean_session_on_exit.after(bevy::window::ExitSystems),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -312,7 +311,6 @@ fn persist_running_session(world: &mut World) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn persist_clean_session_on_exit(
|
||||
mut exits: MessageReader<AppExit>,
|
||||
shutdown: Option<Res<ShutdownCoordinator>>,
|
||||
scene_io: Res<SceneIo>,
|
||||
workspace: Res<ProjectWorkspace>,
|
||||
prefs: Res<UserPreferences>,
|
||||
@ -325,10 +323,6 @@ fn persist_clean_session_on_exit(
|
||||
if exits.read().next().is_none() {
|
||||
return;
|
||||
}
|
||||
if !shutdown.is_some_and(|shutdown| shutdown.authorized_exit_sent()) {
|
||||
warn!("Ignoring an unauthorized AppExit for clean-session persistence");
|
||||
return;
|
||||
}
|
||||
let Some(path) = session_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@ -1,752 +0,0 @@
|
||||
//! Guarded editor shutdown and the handoff to scene persistence.
|
||||
|
||||
use bevy::app::{AppExit, MainScheduleOrder};
|
||||
use bevy::ecs::schedule::ScheduleLabel;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{PrimaryWindow, WindowCloseRequested};
|
||||
|
||||
use crate::native_dialog::NativeDialogBroker;
|
||||
use crate::scene_io::SceneIo;
|
||||
|
||||
const SAVE_ALL_LABEL: &str = "Save All";
|
||||
const DISCARD_LABEL: &str = "Discard";
|
||||
const CANCEL_LABEL: &str = "Cancel";
|
||||
|
||||
/// The user or editor surface that initiated a guarded shutdown.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShutdownSource {
|
||||
NativeWindow,
|
||||
FileMenu,
|
||||
Programmatic,
|
||||
SwitchProject,
|
||||
}
|
||||
|
||||
/// Observable state of the single editor shutdown flow.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ShutdownIntent {
|
||||
#[default]
|
||||
Idle,
|
||||
WaitingForBroker {
|
||||
source: ShutdownSource,
|
||||
},
|
||||
Confirming {
|
||||
source: ShutdownSource,
|
||||
},
|
||||
Saving {
|
||||
source: ShutdownSource,
|
||||
},
|
||||
Authorized {
|
||||
source: ShutdownSource,
|
||||
require_clean_scenes: bool,
|
||||
},
|
||||
ExitSent {
|
||||
source: ShutdownSource,
|
||||
},
|
||||
}
|
||||
|
||||
impl ShutdownIntent {
|
||||
pub fn source(self) -> Option<ShutdownSource> {
|
||||
match self {
|
||||
Self::Idle => None,
|
||||
Self::WaitingForBroker { source }
|
||||
| Self::Confirming { source }
|
||||
| Self::Saving { source }
|
||||
| Self::Authorized { source, .. }
|
||||
| Self::ExitSent { source } => Some(source),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Coordinates all editor exit requests so only an authorized flow emits [`AppExit`].
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct ShutdownCoordinator {
|
||||
intent: ShutdownIntent,
|
||||
coalesced_requests: u32,
|
||||
last_blocker: Option<String>,
|
||||
}
|
||||
|
||||
impl ShutdownCoordinator {
|
||||
pub fn intent(&self) -> ShutdownIntent {
|
||||
self.intent
|
||||
}
|
||||
|
||||
pub fn coalesced_requests(&self) -> u32 {
|
||||
self.coalesced_requests
|
||||
}
|
||||
|
||||
pub fn last_blocker(&self) -> Option<&str> {
|
||||
self.last_blocker.as_deref()
|
||||
}
|
||||
|
||||
pub fn authorized_exit_sent(&self) -> bool {
|
||||
matches!(self.intent, ShutdownIntent::ExitSent { .. })
|
||||
}
|
||||
|
||||
/// Queue a shutdown if no shutdown flow is already active.
|
||||
///
|
||||
/// Repeated window, menu, or programmatic requests are coalesced until the
|
||||
/// current flow is cancelled, fails to save, or sends the authorized exit.
|
||||
pub fn request(&mut self, source: ShutdownSource) -> bool {
|
||||
if self.intent != ShutdownIntent::Idle {
|
||||
self.coalesced_requests = self.coalesced_requests.saturating_add(1);
|
||||
return false;
|
||||
}
|
||||
self.intent = ShutdownIntent::WaitingForBroker { source };
|
||||
self.coalesced_requests = 0;
|
||||
self.last_blocker = None;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_intent_for_test(&mut self, intent: ShutdownIntent) {
|
||||
self.intent = intent;
|
||||
}
|
||||
}
|
||||
|
||||
/// Emitted once when the guarded dialog chooses `Save All`.
|
||||
///
|
||||
/// Scene persistence owns the actual save sequence and must finish it with
|
||||
/// [`complete_shutdown_save`].
|
||||
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShutdownSaveAllRequested {
|
||||
pub source: ShutdownSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ShutdownSaveOutcome {
|
||||
Saved,
|
||||
Cancelled,
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ShutdownDecision {
|
||||
SaveAll,
|
||||
Discard,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// Runs after Bevy's `Last` schedule so no editor authoring system can mutate
|
||||
/// a document after the final dirty-state check in the same frame.
|
||||
#[derive(ScheduleLabel, Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct EditorShutdownFinalize;
|
||||
|
||||
/// Queue a guarded shutdown from an exclusive-world UI or workflow callback.
|
||||
pub fn request_editor_shutdown(world: &mut World, source: ShutdownSource) -> bool {
|
||||
world
|
||||
.get_resource_mut::<ShutdownCoordinator>()
|
||||
.is_some_and(|mut coordinator| coordinator.request(source))
|
||||
}
|
||||
|
||||
/// Queue a project-browser handoff through the same dirty-document guard as editor exit.
|
||||
pub fn request_project_switch(world: &mut World) -> bool {
|
||||
request_editor_shutdown(world, ShutdownSource::SwitchProject)
|
||||
}
|
||||
|
||||
/// Resolve the dirty-scene confirmation through the shared coordinator.
|
||||
pub fn resolve_shutdown_decision(world: &mut World, decision: ShutdownDecision) -> bool {
|
||||
let (save_request, status) = {
|
||||
let Some(mut coordinator) = world.get_resource_mut::<ShutdownCoordinator>() else {
|
||||
return false;
|
||||
};
|
||||
let ShutdownIntent::Confirming { source } = coordinator.intent else {
|
||||
return false;
|
||||
};
|
||||
match decision {
|
||||
ShutdownDecision::SaveAll => {
|
||||
coordinator.intent = ShutdownIntent::Saving { source };
|
||||
(
|
||||
Some(source),
|
||||
Some("Saving all modified scene tabs before shutdown"),
|
||||
)
|
||||
}
|
||||
ShutdownDecision::Discard => {
|
||||
coordinator.intent = ShutdownIntent::Authorized {
|
||||
source,
|
||||
require_clean_scenes: false,
|
||||
};
|
||||
(None, None)
|
||||
}
|
||||
ShutdownDecision::Cancel => {
|
||||
coordinator.intent = ShutdownIntent::Idle;
|
||||
(
|
||||
None,
|
||||
Some("Shutdown cancelled; unsaved scene tabs retained"),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(status) = status {
|
||||
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
|
||||
scene_io.set_status(status);
|
||||
}
|
||||
}
|
||||
if let Some(source) = save_request {
|
||||
world.write_message(ShutdownSaveAllRequested { source });
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Finish the save-all handoff. A reported success is authorized only after
|
||||
/// every scene tab is observably clean.
|
||||
pub fn complete_shutdown_save(world: &mut World, outcome: ShutdownSaveOutcome) -> bool {
|
||||
let scenes_are_clean = !scene_has_unsaved_changes(world);
|
||||
let Some(mut coordinator) = world.get_resource_mut::<ShutdownCoordinator>() else {
|
||||
return false;
|
||||
};
|
||||
let ShutdownIntent::Saving { source } = coordinator.intent else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match outcome {
|
||||
ShutdownSaveOutcome::Saved if scenes_are_clean => {
|
||||
coordinator.intent = ShutdownIntent::Authorized {
|
||||
source,
|
||||
require_clean_scenes: true,
|
||||
};
|
||||
coordinator.last_blocker = None;
|
||||
true
|
||||
}
|
||||
ShutdownSaveOutcome::Saved => {
|
||||
coordinator.intent = ShutdownIntent::Idle;
|
||||
coordinator.last_blocker =
|
||||
Some("Save All completed while unsaved scene tabs remain".into());
|
||||
false
|
||||
}
|
||||
ShutdownSaveOutcome::Cancelled => {
|
||||
coordinator.intent = ShutdownIntent::Idle;
|
||||
coordinator.last_blocker = None;
|
||||
false
|
||||
}
|
||||
ShutdownSaveOutcome::Failed(error) => {
|
||||
coordinator.intent = ShutdownIntent::Idle;
|
||||
coordinator.last_blocker = Some(error);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ShutdownPlugin;
|
||||
|
||||
impl Plugin for ShutdownPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_schedule(EditorShutdownFinalize);
|
||||
app.world_mut()
|
||||
.resource_mut::<MainScheduleOrder>()
|
||||
.insert_after(Last, EditorShutdownFinalize);
|
||||
app.init_resource::<ShutdownCoordinator>()
|
||||
.add_message::<ShutdownSaveAllRequested>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(capture_native_close_requests, drive_shutdown).chain(),
|
||||
)
|
||||
.add_systems(EditorShutdownFinalize, finalize_authorized_shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
fn capture_native_close_requests(
|
||||
mut commands: Commands,
|
||||
mut close_requests: MessageReader<WindowCloseRequested>,
|
||||
primary_window: Query<Entity, With<PrimaryWindow>>,
|
||||
mut coordinator: ResMut<ShutdownCoordinator>,
|
||||
) {
|
||||
let Ok(primary_window) = primary_window.single() else {
|
||||
return;
|
||||
};
|
||||
for request in close_requests.read() {
|
||||
if request.window == primary_window {
|
||||
coordinator.request(ShutdownSource::NativeWindow);
|
||||
} else {
|
||||
commands.entity(request.window).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drive_shutdown(world: &mut World) {
|
||||
let intent = world.resource::<ShutdownCoordinator>().intent;
|
||||
match intent {
|
||||
ShutdownIntent::Idle
|
||||
| ShutdownIntent::Confirming { .. }
|
||||
| ShutdownIntent::Saving { .. }
|
||||
| ShutdownIntent::ExitSent { .. } => {}
|
||||
ShutdownIntent::WaitingForBroker { source } => {
|
||||
let unsaved_count = unsaved_scene_count(world);
|
||||
if unsaved_count == 0 {
|
||||
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::Authorized {
|
||||
source,
|
||||
require_clean_scenes: true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
if world.resource::<NativeDialogBroker>().is_pending() {
|
||||
return;
|
||||
}
|
||||
|
||||
let action = if source == ShutdownSource::SwitchProject {
|
||||
"switching projects"
|
||||
} else {
|
||||
"quitting"
|
||||
};
|
||||
let description = if unsaved_count == 1 {
|
||||
format!("One scene tab has unsaved changes. Save it before {action}?")
|
||||
} else {
|
||||
format!(
|
||||
"{unsaved_count} scene tabs have unsaved changes. Save all before {action}?"
|
||||
)
|
||||
};
|
||||
let title = if source == ShutdownSource::SwitchProject {
|
||||
"Switch Project"
|
||||
} else {
|
||||
"Quit Blacksite Editor"
|
||||
};
|
||||
let request = world.resource::<NativeDialogBroker>().request(
|
||||
move || {
|
||||
rfd::MessageDialog::new()
|
||||
.set_title(title)
|
||||
.set_description(description)
|
||||
.set_level(rfd::MessageLevel::Warning)
|
||||
.set_buttons(rfd::MessageButtons::YesNoCancelCustom(
|
||||
SAVE_ALL_LABEL.into(),
|
||||
DISCARD_LABEL.into(),
|
||||
CANCEL_LABEL.into(),
|
||||
))
|
||||
.show()
|
||||
},
|
||||
|world, result| {
|
||||
resolve_shutdown_decision(world, decision_from_dialog(result));
|
||||
},
|
||||
);
|
||||
if request.is_ok() {
|
||||
world.resource_mut::<ShutdownCoordinator>().intent =
|
||||
ShutdownIntent::Confirming { source };
|
||||
}
|
||||
}
|
||||
ShutdownIntent::Authorized { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Final dirty-state recheck after editor UI and authoring schedules have completed.
|
||||
pub(crate) fn finalize_authorized_shutdown(world: &mut World) {
|
||||
let (source, require_clean_scenes) = {
|
||||
let coordinator = world.resource::<ShutdownCoordinator>();
|
||||
let ShutdownIntent::Authorized {
|
||||
source,
|
||||
require_clean_scenes,
|
||||
} = coordinator.intent
|
||||
else {
|
||||
return;
|
||||
};
|
||||
(source, require_clean_scenes)
|
||||
};
|
||||
if require_clean_scenes && scene_has_unsaved_changes(world) {
|
||||
world.resource_mut::<ShutdownCoordinator>().intent =
|
||||
ShutdownIntent::WaitingForBroker { source };
|
||||
return;
|
||||
}
|
||||
if source == ShutdownSource::SwitchProject {
|
||||
if let Err(error) = crate::launcher::spawn_project_launcher_process() {
|
||||
let message = format!("Switch project failed: {error}");
|
||||
{
|
||||
let mut coordinator = world.resource_mut::<ShutdownCoordinator>();
|
||||
coordinator.intent = ShutdownIntent::Idle;
|
||||
coordinator.last_blocker = Some(message.clone());
|
||||
}
|
||||
if let Some(mut scene_io) = world.get_resource_mut::<SceneIo>() {
|
||||
scene_io.set_status(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::ExitSent { source };
|
||||
debug!(?source, "Editor shutdown authorized");
|
||||
world.write_message(AppExit::Success);
|
||||
}
|
||||
|
||||
fn scene_has_unsaved_changes(world: &World) -> bool {
|
||||
world
|
||||
.get_resource::<SceneIo>()
|
||||
.is_some_and(SceneIo::has_unsaved_tabs)
|
||||
}
|
||||
|
||||
fn unsaved_scene_count(world: &World) -> usize {
|
||||
let Some(scene_io) = world.get_resource::<SceneIo>() else {
|
||||
return 0;
|
||||
};
|
||||
let tab_count = scene_io.tabs.iter().filter(|tab| tab.dirty).count();
|
||||
if scene_io.dirty
|
||||
&& scene_io
|
||||
.tabs
|
||||
.get(scene_io.active_tab)
|
||||
.is_none_or(|tab| !tab.dirty)
|
||||
{
|
||||
tab_count + 1
|
||||
} else {
|
||||
tab_count
|
||||
}
|
||||
}
|
||||
|
||||
fn decision_from_dialog(result: rfd::MessageDialogResult) -> ShutdownDecision {
|
||||
match result {
|
||||
rfd::MessageDialogResult::Custom(label) if label == SAVE_ALL_LABEL => {
|
||||
ShutdownDecision::SaveAll
|
||||
}
|
||||
rfd::MessageDialogResult::Custom(label) if label == DISCARD_LABEL => {
|
||||
ShutdownDecision::Discard
|
||||
}
|
||||
rfd::MessageDialogResult::Yes => ShutdownDecision::SaveAll,
|
||||
rfd::MessageDialogResult::No => ShutdownDecision::Discard,
|
||||
_ => ShutdownDecision::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::mpsc;
|
||||
|
||||
use bevy::ecs::message::MessageCursor;
|
||||
|
||||
use super::*;
|
||||
use crate::native_dialog::NativeDialogPlugin;
|
||||
|
||||
fn confirming_world() -> World {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Messages<ShutdownSaveAllRequested>>();
|
||||
world.insert_resource(ShutdownCoordinator {
|
||||
intent: ShutdownIntent::Confirming {
|
||||
source: ShutdownSource::FileMenu,
|
||||
},
|
||||
..default()
|
||||
});
|
||||
world
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_requests_are_coalesced_behind_the_first_source() {
|
||||
let mut coordinator = ShutdownCoordinator::default();
|
||||
|
||||
assert!(coordinator.request(ShutdownSource::NativeWindow));
|
||||
assert!(!coordinator.request(ShutdownSource::FileMenu));
|
||||
assert_eq!(coordinator.coalesced_requests(), 1);
|
||||
assert_eq!(
|
||||
coordinator.intent(),
|
||||
ShutdownIntent::WaitingForBroker {
|
||||
source: ShutdownSource::NativeWindow
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_returns_to_idle_and_discard_authorizes_exit() {
|
||||
let mut cancel_world = confirming_world();
|
||||
assert!(resolve_shutdown_decision(
|
||||
&mut cancel_world,
|
||||
ShutdownDecision::Cancel
|
||||
));
|
||||
assert_eq!(
|
||||
cancel_world.resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::Idle
|
||||
);
|
||||
|
||||
let mut discard_world = confirming_world();
|
||||
assert!(resolve_shutdown_decision(
|
||||
&mut discard_world,
|
||||
ShutdownDecision::Discard
|
||||
));
|
||||
assert_eq!(
|
||||
discard_world.resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::Authorized {
|
||||
source: ShutdownSource::FileMenu,
|
||||
require_clean_scenes: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirty_cancel_preserves_the_session_and_discard_emits_one_exit() {
|
||||
let mut app = App::new();
|
||||
app.add_message::<AppExit>()
|
||||
.init_resource::<SceneIo>()
|
||||
.init_resource::<ShutdownCoordinator>();
|
||||
app.world_mut().resource_mut::<SceneIo>().mark_dirty();
|
||||
app.world_mut()
|
||||
.resource_mut::<ShutdownCoordinator>()
|
||||
.set_intent_for_test(ShutdownIntent::Confirming {
|
||||
source: ShutdownSource::Programmatic,
|
||||
});
|
||||
|
||||
assert!(resolve_shutdown_decision(
|
||||
app.world_mut(),
|
||||
ShutdownDecision::Cancel
|
||||
));
|
||||
drive_shutdown(app.world_mut());
|
||||
finalize_authorized_shutdown(app.world_mut());
|
||||
assert_eq!(app.should_exit(), None);
|
||||
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ShutdownCoordinator>()
|
||||
.set_intent_for_test(ShutdownIntent::Confirming {
|
||||
source: ShutdownSource::Programmatic,
|
||||
});
|
||||
assert!(resolve_shutdown_decision(
|
||||
app.world_mut(),
|
||||
ShutdownDecision::Discard
|
||||
));
|
||||
drive_shutdown(app.world_mut());
|
||||
finalize_authorized_shutdown(app.world_mut());
|
||||
assert_eq!(app.should_exit(), Some(AppExit::Success));
|
||||
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
|
||||
assert!(app
|
||||
.world()
|
||||
.resource::<ShutdownCoordinator>()
|
||||
.authorized_exit_sent());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_menu_and_programmatic_clean_exit_use_the_same_coordinator_path() {
|
||||
for source in [ShutdownSource::FileMenu, ShutdownSource::Programmatic] {
|
||||
let mut app = App::new();
|
||||
app.add_message::<AppExit>()
|
||||
.init_resource::<SceneIo>()
|
||||
.init_resource::<ShutdownCoordinator>()
|
||||
.init_resource::<NativeDialogBroker>();
|
||||
|
||||
assert!(request_editor_shutdown(app.world_mut(), source));
|
||||
drive_shutdown(app.world_mut());
|
||||
finalize_authorized_shutdown(app.world_mut());
|
||||
|
||||
assert_eq!(app.should_exit(), Some(AppExit::Success));
|
||||
assert_eq!(
|
||||
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::ExitSent { source }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_all_emits_one_persistence_handoff() {
|
||||
let mut world = confirming_world();
|
||||
let mut cursor = MessageCursor::<ShutdownSaveAllRequested>::default();
|
||||
|
||||
assert!(resolve_shutdown_decision(
|
||||
&mut world,
|
||||
ShutdownDecision::SaveAll
|
||||
));
|
||||
assert!(!resolve_shutdown_decision(
|
||||
&mut world,
|
||||
ShutdownDecision::SaveAll
|
||||
));
|
||||
|
||||
let messages = world.resource::<Messages<ShutdownSaveAllRequested>>();
|
||||
let requests: Vec<_> = cursor.read(messages).copied().collect();
|
||||
assert_eq!(
|
||||
requests,
|
||||
vec![ShutdownSaveAllRequested {
|
||||
source: ShutdownSource::FileMenu
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_native_close_emits_the_authorized_exit() {
|
||||
let mut app = App::new();
|
||||
app.add_message::<WindowCloseRequested>()
|
||||
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
|
||||
.init_resource::<SceneIo>();
|
||||
let window = app.world_mut().spawn(PrimaryWindow).id();
|
||||
app.world_mut()
|
||||
.write_message(WindowCloseRequested { window });
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(app.should_exit(), Some(AppExit::Success));
|
||||
assert_eq!(
|
||||
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::ExitSent {
|
||||
source: ShutdownSource::NativeWindow
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_window_close_does_not_request_editor_shutdown() {
|
||||
let mut app = App::new();
|
||||
app.add_message::<WindowCloseRequested>()
|
||||
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
|
||||
.init_resource::<SceneIo>();
|
||||
app.world_mut().spawn(PrimaryWindow);
|
||||
let secondary = app.world_mut().spawn_empty().id();
|
||||
app.world_mut()
|
||||
.write_message(WindowCloseRequested { window: secondary });
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(app.should_exit(), None);
|
||||
assert_eq!(
|
||||
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::Idle
|
||||
);
|
||||
assert!(app.world().get_entity(secondary).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_dialog_broker_keeps_shutdown_waiting() {
|
||||
let mut app = App::new();
|
||||
app.add_message::<WindowCloseRequested>()
|
||||
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
|
||||
.init_resource::<SceneIo>();
|
||||
app.world_mut().resource_mut::<SceneIo>().mark_dirty();
|
||||
let (release_sender, release_receiver) = mpsc::channel();
|
||||
app.world()
|
||||
.resource::<NativeDialogBroker>()
|
||||
.request(
|
||||
move || release_receiver.recv().expect("release sender dropped"),
|
||||
|_, _: ()| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(request_editor_shutdown(
|
||||
app.world_mut(),
|
||||
ShutdownSource::Programmatic
|
||||
));
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::WaitingForBroker {
|
||||
source: ShutdownSource::Programmatic
|
||||
}
|
||||
);
|
||||
release_sender.send(()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_save_requires_every_scene_tab_to_be_clean() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SceneIo>();
|
||||
world.insert_resource(ShutdownCoordinator {
|
||||
intent: ShutdownIntent::Saving {
|
||||
source: ShutdownSource::Programmatic,
|
||||
},
|
||||
..default()
|
||||
});
|
||||
|
||||
assert!(complete_shutdown_save(
|
||||
&mut world,
|
||||
ShutdownSaveOutcome::Saved
|
||||
));
|
||||
assert_eq!(
|
||||
world.resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::Authorized {
|
||||
source: ShutdownSource::Programmatic,
|
||||
require_clean_scenes: true,
|
||||
}
|
||||
);
|
||||
|
||||
world.resource_mut::<SceneIo>().mark_dirty();
|
||||
world.resource_mut::<ShutdownCoordinator>().intent = ShutdownIntent::Saving {
|
||||
source: ShutdownSource::Programmatic,
|
||||
};
|
||||
assert!(!complete_shutdown_save(
|
||||
&mut world,
|
||||
ShutdownSaveOutcome::Saved
|
||||
));
|
||||
assert_eq!(
|
||||
world.resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::Idle
|
||||
);
|
||||
assert!(world
|
||||
.resource::<ShutdownCoordinator>()
|
||||
.last_blocker()
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_edit_after_save_reopens_the_guard_instead_of_exiting() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<Messages<AppExit>>();
|
||||
world.init_resource::<SceneIo>();
|
||||
world.insert_resource(ShutdownCoordinator {
|
||||
intent: ShutdownIntent::Authorized {
|
||||
source: ShutdownSource::Programmatic,
|
||||
require_clean_scenes: true,
|
||||
},
|
||||
..default()
|
||||
});
|
||||
world.resource_mut::<SceneIo>().mark_dirty();
|
||||
|
||||
finalize_authorized_shutdown(&mut world);
|
||||
|
||||
assert_eq!(
|
||||
world.resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::WaitingForBroker {
|
||||
source: ShutdownSource::Programmatic
|
||||
}
|
||||
);
|
||||
let messages = world.resource::<Messages<AppExit>>();
|
||||
assert_eq!(
|
||||
MessageCursor::<AppExit>::default().read(messages).count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_schedule_edit_is_seen_before_final_exit_authorization() {
|
||||
let mut app = App::new();
|
||||
app.add_message::<WindowCloseRequested>()
|
||||
.add_plugins((NativeDialogPlugin, ShutdownPlugin))
|
||||
.init_resource::<SceneIo>()
|
||||
.add_systems(Last, |mut scene_io: ResMut<SceneIo>| {
|
||||
scene_io.mark_dirty();
|
||||
});
|
||||
assert!(request_editor_shutdown(
|
||||
app.world_mut(),
|
||||
ShutdownSource::Programmatic
|
||||
));
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(app.should_exit(), None);
|
||||
assert!(app.world().resource::<SceneIo>().has_unsaved_tabs());
|
||||
assert_eq!(
|
||||
app.world().resource::<ShutdownCoordinator>().intent(),
|
||||
ShutdownIntent::WaitingForBroker {
|
||||
source: ShutdownSource::Programmatic
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_dialog_labels_map_to_shutdown_decisions() {
|
||||
assert_eq!(
|
||||
decision_from_dialog(rfd::MessageDialogResult::Custom(SAVE_ALL_LABEL.into())),
|
||||
ShutdownDecision::SaveAll
|
||||
);
|
||||
assert_eq!(
|
||||
decision_from_dialog(rfd::MessageDialogResult::Custom(DISCARD_LABEL.into())),
|
||||
ShutdownDecision::Discard
|
||||
);
|
||||
assert_eq!(
|
||||
decision_from_dialog(rfd::MessageDialogResult::Custom(CANCEL_LABEL.into())),
|
||||
ShutdownDecision::Cancel
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_final_coordinator_state_authorizes_clean_session_persistence() {
|
||||
let mut coordinator = ShutdownCoordinator::default();
|
||||
assert!(!coordinator.authorized_exit_sent());
|
||||
|
||||
coordinator.intent = ShutdownIntent::Authorized {
|
||||
source: ShutdownSource::Programmatic,
|
||||
require_clean_scenes: true,
|
||||
};
|
||||
assert!(!coordinator.authorized_exit_sent());
|
||||
|
||||
coordinator.intent = ShutdownIntent::ExitSent {
|
||||
source: ShutdownSource::Programmatic,
|
||||
};
|
||||
assert!(coordinator.authorized_exit_sent());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -47,11 +47,6 @@ pub struct EditorComponentRegistry {
|
||||
inspectors: HashMap<&'static str, ComponentInspectorFn>,
|
||||
}
|
||||
|
||||
/// Synchronous inspector callbacks run while [`crate::ui::UiState`] is scoped out of `World`.
|
||||
/// They must not request that resource. Keep extension-owned transient state in independently
|
||||
/// registered resources, use [`crate::ui::request_ui_selection`] and
|
||||
/// [`crate::ui::request_editor_tab`] for host UI changes, or defer other cross-panel work through
|
||||
/// extension-owned commands or events.
|
||||
pub type ComponentInspectorFn = fn(&mut World, &mut egui::Ui, Entity);
|
||||
|
||||
impl EditorComponentRegistry {
|
||||
|
||||
@ -13,6 +13,8 @@ use crate::history::set_brush_with_history;
|
||||
use crate::history::EditorHistory;
|
||||
use crate::project_io::ProjectWorkspace;
|
||||
use crate::scene_io::{SceneIo, SceneIoEventSeverity};
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::ui::UiState;
|
||||
use crate::viewport::collider_diagnostics::{
|
||||
ColliderDiagnosticSeverity, ColliderDiagnosticsState, ColliderOverlayStatus,
|
||||
};
|
||||
@ -121,7 +123,11 @@ pub fn diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
|
||||
.iter(world)
|
||||
.find_map(|(entity, id)| (id.0 == actor_id).then_some(entity))
|
||||
{
|
||||
request_diagnostic_selection(world, entity);
|
||||
world.resource_mut::<SelectedEntity>().0 = Some(entity);
|
||||
world
|
||||
.resource_mut::<UiState>()
|
||||
.selected_entities
|
||||
.select_replace(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -244,7 +250,11 @@ fn collider_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
|
||||
ui.colored_label(color, row.shape_label);
|
||||
ui.label(&row.actor_name);
|
||||
if ui.small_button("Select").clicked() {
|
||||
request_diagnostic_selection(world, row.entity);
|
||||
world.resource_mut::<SelectedEntity>().0 = Some(row.entity);
|
||||
world
|
||||
.resource_mut::<UiState>()
|
||||
.selected_entities
|
||||
.select_replace(row.entity);
|
||||
}
|
||||
});
|
||||
for diagnostic in &row.diagnostics {
|
||||
@ -294,7 +304,11 @@ fn brush_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
|
||||
ui.colored_label(color, row.status_label());
|
||||
ui.label(row.name.as_str());
|
||||
if ui.button("Select").clicked() {
|
||||
request_diagnostic_selection(world, row.entity);
|
||||
world.resource_mut::<SelectedEntity>().0 = Some(row.entity);
|
||||
world
|
||||
.resource_mut::<UiState>()
|
||||
.selected_entities
|
||||
.select_replace(row.entity);
|
||||
}
|
||||
if ui
|
||||
.add_enabled(row.error_count > 0, egui::Button::new("Reset Cube"))
|
||||
@ -309,10 +323,6 @@ fn brush_diagnostics_ui(world: &mut World, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn request_diagnostic_selection(world: &mut World, entity: Entity) {
|
||||
crate::ui::request_ui_selection(world, &[entity]);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BrushDiagnosticRow {
|
||||
entity: Entity,
|
||||
@ -387,37 +397,3 @@ pub fn diagnostics_window(world: &mut World, ctx: &egui::Context, open: &mut boo
|
||||
diagnostics_ui(world, ui);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::ui::{PendingUiSelection, UiState};
|
||||
|
||||
#[test]
|
||||
fn diagnostic_selection_reconciles_while_ui_state_is_scoped_out() {
|
||||
let before = Entity::from_bits(1);
|
||||
let requested = Entity::from_bits(2);
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SelectedEntity>();
|
||||
world.init_resource::<PendingUiSelection>();
|
||||
world.resource_mut::<SelectedEntity>().0 = Some(before);
|
||||
|
||||
let mut ui_state = UiState::default_layout();
|
||||
ui_state.selected_entities.select_replace(before);
|
||||
world.insert_resource(ui_state);
|
||||
|
||||
world.resource_scope::<UiState, _>(|world, mut ui_state| {
|
||||
assert!(!world.contains_resource::<UiState>());
|
||||
request_diagnostic_selection(world, requested);
|
||||
let pending = world.resource_mut::<PendingUiSelection>().0.take();
|
||||
super::super::reconcile_operator_selection(
|
||||
&mut ui_state.selected_entities,
|
||||
&[before],
|
||||
world.resource::<SelectedEntity>().0,
|
||||
pending.as_deref(),
|
||||
);
|
||||
assert_eq!(ui_state.selected_entities.as_slice(), &[requested]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ use crate::history::{
|
||||
set_primitive_with_history, set_rigid_body_with_history, set_static_mesh_renderer_with_history,
|
||||
EditorEntitySnapshot,
|
||||
};
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::ui::theme::{
|
||||
panel_heading, BORDER, ELEVATED_BG, PANEL_BG_DARK, SELECTION_BG_MUTED, TEXT_DIM, TEXT_MUTED,
|
||||
WIDGET_BG,
|
||||
@ -50,8 +51,9 @@ use crate::viewport::collider_diagnostics::{
|
||||
use super::component_registry::{
|
||||
EditorComponentCategory, EditorComponentDescriptor, EditorComponentRegistry,
|
||||
};
|
||||
use super::dock_tabs::open_and_focus_tab;
|
||||
use super::helpers::create_scene_sun_override_from_project_settings;
|
||||
use super::EditorTab;
|
||||
use super::{EditorTab, UiState};
|
||||
use crate::assets::asset_db::{find_asset_by_path, AssetRegistry};
|
||||
use crate::assets::static_mesh::{
|
||||
load_static_mesh_manifest, material_id_from_label, part_id_from_label,
|
||||
@ -133,7 +135,6 @@ pub(crate) struct InspectorPanelState {
|
||||
add_component_scroll_selected: bool,
|
||||
add_component_selected_index: usize,
|
||||
add_component_target: Option<Entity>,
|
||||
collapsed_components: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@ -275,9 +276,13 @@ pub(crate) fn component_card_context(
|
||||
state_key,
|
||||
);
|
||||
let collapsed = world
|
||||
.resource::<InspectorPanelState>()
|
||||
.collapsed_components
|
||||
.contains(&component_card_key(world, entity, options.type_name));
|
||||
.get_resource::<UiState>()
|
||||
.map(|state| {
|
||||
state
|
||||
.inspector_collapsed_components
|
||||
.contains(&component_card_key(world, entity, options.type_name))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
let pasteable = options.copyable
|
||||
&& world
|
||||
.get_resource::<InspectorClipboard>()
|
||||
@ -321,11 +326,12 @@ fn apply_component_card_response_for_type(
|
||||
) {
|
||||
if let Some(collapsed) = response.collapsed {
|
||||
let key = component_card_key(world, entity, type_name);
|
||||
let mut panel_state = world.resource_mut::<InspectorPanelState>();
|
||||
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
|
||||
if collapsed {
|
||||
panel_state.collapsed_components.insert(key);
|
||||
ui_state.inspector_collapsed_components.insert(key);
|
||||
} else {
|
||||
panel_state.collapsed_components.remove(&key);
|
||||
ui_state.inspector_collapsed_components.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2356,15 +2362,18 @@ fn locate_asset_ref(
|
||||
return;
|
||||
};
|
||||
|
||||
reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
|
||||
}
|
||||
|
||||
fn reveal_asset_in_browser(world: &mut World, folder_path: &str, selection: &AssetSelection) {
|
||||
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
|
||||
assets.current_folder = folder_path.to_string();
|
||||
assets.select(selection.clone());
|
||||
assets.current_folder = candidate.folder_path.clone();
|
||||
assets.select(candidate.selection.clone());
|
||||
}
|
||||
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
|
||||
let panel_nodes = ui_state.panel_nodes;
|
||||
open_and_focus_tab(
|
||||
&mut ui_state.dock_state,
|
||||
EditorTab::AssetBrowser,
|
||||
&panel_nodes,
|
||||
);
|
||||
}
|
||||
super::request_editor_tab(world, EditorTab::AssetBrowser);
|
||||
}
|
||||
|
||||
fn skinned_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
@ -4616,7 +4625,10 @@ pub fn project_sun_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
|
||||
if create_override {
|
||||
let sun = create_scene_sun_override_from_project_settings(world);
|
||||
crate::ui::request_ui_selection(world, &[sun]);
|
||||
world.resource_mut::<SelectedEntity>().0 = Some(sun);
|
||||
if let Some(mut ui_state) = world.get_resource_mut::<crate::ui::UiState>() {
|
||||
ui_state.selected_entities.select_replace(sun);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4869,7 +4881,18 @@ fn locate_texture_asset(
|
||||
return;
|
||||
};
|
||||
|
||||
reveal_asset_in_browser(world, &candidate.folder_path, &candidate.selection);
|
||||
if let Some(mut assets) = world.get_resource_mut::<EditorAssets>() {
|
||||
assets.current_folder = candidate.folder_path.clone();
|
||||
assets.select(candidate.selection.clone());
|
||||
}
|
||||
if let Some(mut ui_state) = world.get_resource_mut::<UiState>() {
|
||||
let panel_nodes = ui_state.panel_nodes;
|
||||
open_and_focus_tab(
|
||||
&mut ui_state.dock_state,
|
||||
EditorTab::AssetBrowser,
|
||||
&panel_nodes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>) -> bool {
|
||||
@ -4901,80 +4924,6 @@ fn option_string_ui(ui: &mut egui::Ui, label: &str, value: &mut Option<String>)
|
||||
mod collider_inspector_tests {
|
||||
use super::*;
|
||||
use crate::history::{apply_command_undo, EditorHistory};
|
||||
use crate::ui::{DockTabRequest, UiState};
|
||||
|
||||
#[test]
|
||||
fn component_collapse_persists_while_ui_state_is_scoped_out() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EditorComponentRegistry>();
|
||||
world.init_resource::<InspectorPanelState>();
|
||||
let entity = world
|
||||
.spawn((ActorId::new("collapsed-component"), Transform::IDENTITY))
|
||||
.id();
|
||||
world.insert_resource(UiState::default_layout());
|
||||
|
||||
world.resource_scope::<UiState, _>(|world, _ui_state| {
|
||||
assert!(!world.contains_resource::<UiState>());
|
||||
apply_component_card_response(
|
||||
world,
|
||||
entity,
|
||||
ComponentCardResponse {
|
||||
type_name: COMPONENT_TRANSFORM,
|
||||
collapsed: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let context = component_card_context(
|
||||
world,
|
||||
entity,
|
||||
ComponentCardOptions::fixed(COMPONENT_TRANSFORM, "Transform", icons::CUBE),
|
||||
);
|
||||
assert!(context.collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_texture_requests_asset_browser_while_ui_state_is_scoped_out() {
|
||||
let path = "assets/textures/scoped-locate.png";
|
||||
let folder = "assets/textures";
|
||||
let selection = AssetSelection::File(path.to_string());
|
||||
let mut world = World::new();
|
||||
world.insert_resource(EditorAssets {
|
||||
folders: Vec::new(),
|
||||
assets: vec![EditorAsset {
|
||||
label: "Scoped Locate".into(),
|
||||
path: Some(path.into()),
|
||||
folder_path: folder.into(),
|
||||
kind: EditorAssetKind::Texture,
|
||||
}],
|
||||
current_folder: crate::assets::ASSETS_ROOT.into(),
|
||||
selected: None,
|
||||
dragging: None,
|
||||
status: String::new(),
|
||||
});
|
||||
world.init_resource::<DockTabRequest>();
|
||||
world.insert_resource(UiState::default_layout());
|
||||
let candidate = TextureAssetCandidate {
|
||||
label: "Scoped Locate".into(),
|
||||
path: path.into(),
|
||||
folder_path: folder.into(),
|
||||
selection: selection.clone(),
|
||||
texture_id: None,
|
||||
};
|
||||
|
||||
world.resource_scope::<UiState, _>(|world, _ui_state| {
|
||||
assert!(!world.contains_resource::<UiState>());
|
||||
locate_texture_asset(world, Some(path), &[candidate]);
|
||||
});
|
||||
|
||||
let assets = world.resource::<EditorAssets>();
|
||||
assert_eq!(assets.current_folder, folder);
|
||||
assert_eq!(assets.selected.as_ref(), Some(&selection));
|
||||
assert_eq!(
|
||||
world.resource::<DockTabRequest>().0,
|
||||
Some(EditorTab::AssetBrowser)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_switch_preserves_dimensions_and_is_one_undoable_edit() {
|
||||
|
||||
@ -9,7 +9,6 @@ use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
|
||||
use crate::project::samples::{SampleCatalog, SampleCatalogEntry};
|
||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||
use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel};
|
||||
use crate::shutdown::{request_editor_shutdown, ShutdownSource};
|
||||
use crate::state::{EditorMode, PlayPaused};
|
||||
|
||||
use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
|
||||
@ -40,7 +39,6 @@ pub fn top_menu_bar(
|
||||
selected: &SelectedEntities,
|
||||
dock_state: &mut DockState<EditorTab>,
|
||||
panel_nodes: &mut PanelNodes,
|
||||
viewport_rect: egui::Rect,
|
||||
) {
|
||||
egui::Panel::top("editor_menu_bar").show_inside(root_ui, |ui| {
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
@ -150,11 +148,6 @@ pub fn top_menu_bar(
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Quit", None, true).clicked() {
|
||||
request_editor_shutdown(world, ShutdownSource::FileMenu);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
|
||||
ui.menu_button("Edit", |ui| {
|
||||
@ -210,7 +203,7 @@ pub fn top_menu_bar(
|
||||
("Create Link", NavigationActorType::Link),
|
||||
] {
|
||||
if menu_item(ui, label, None, true).clicked() {
|
||||
spawn_navigation_actor(world, actor_type, viewport_rect);
|
||||
spawn_navigation_actor(world, actor_type);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,8 @@ mod toolbar;
|
||||
mod viewport_chrome;
|
||||
mod widgets;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow};
|
||||
use bevy_egui::{egui, EguiContext, EguiPrimaryContextPass, PrimaryEguiContext};
|
||||
@ -69,6 +71,7 @@ pub struct UiState {
|
||||
pub selected_entities: SelectedEntities,
|
||||
pub renaming_entity: Option<Entity>,
|
||||
pub rename_buffer: String,
|
||||
pub(crate) inspector_collapsed_components: HashSet<String>,
|
||||
pub(crate) panel_nodes: PanelNodes,
|
||||
last_mode_tab: Option<EditorMode>,
|
||||
}
|
||||
@ -76,56 +79,6 @@ pub struct UiState {
|
||||
#[derive(Resource, Default)]
|
||||
struct DockTabRequest(Option<EditorTab>);
|
||||
|
||||
#[derive(Resource, Default, Debug)]
|
||||
pub(crate) struct PendingUiSelection(pub(crate) Option<Vec<Entity>>);
|
||||
|
||||
fn replace_selected_entities(selected: &mut SelectedEntities, entities: &[Entity]) {
|
||||
selected.clear();
|
||||
for (index, entity) in entities.iter().enumerate() {
|
||||
if index == 0 {
|
||||
selected.select_replace(*entity);
|
||||
} else {
|
||||
selected.select_maybe_add(*entity, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests an editor selection change from synchronous UI or extension callbacks.
|
||||
///
|
||||
/// Returns `false` when the editor selection resources are not installed. A normally configured
|
||||
/// editor provides them through [`crate::EditorPluginGroup`].
|
||||
pub fn request_ui_selection(world: &mut World, entities: &[Entity]) -> bool {
|
||||
{
|
||||
let Some(mut selected) = world.get_resource_mut::<SelectedEntity>() else {
|
||||
return false;
|
||||
};
|
||||
selected.0 = entities.first().copied();
|
||||
}
|
||||
if world.contains_resource::<UiState>() {
|
||||
{
|
||||
let mut ui_state = world.resource_mut::<UiState>();
|
||||
replace_selected_entities(&mut ui_state.selected_entities, entities);
|
||||
}
|
||||
if let Some(mut pending) = world.get_resource_mut::<PendingUiSelection>() {
|
||||
pending.0 = None;
|
||||
}
|
||||
} else if let Some(mut pending) = world.get_resource_mut::<PendingUiSelection>() {
|
||||
pending.0 = Some(entities.to_vec());
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Requests that an editor tab be opened and focused after the current synchronous UI pass.
|
||||
///
|
||||
/// Returns `false` when [`EditorUiPlugin`] is not installed.
|
||||
pub fn request_editor_tab(world: &mut World, tab: EditorTab) -> bool {
|
||||
let Some(mut request) = world.get_resource_mut::<DockTabRequest>() else {
|
||||
return false;
|
||||
};
|
||||
request.0 = Some(tab);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool {
|
||||
ctx.egui_wants_keyboard_input()
|
||||
}
|
||||
@ -173,6 +126,7 @@ impl UiState {
|
||||
selected_entities: SelectedEntities::default(),
|
||||
renaming_entity: None,
|
||||
rename_buffer: String::new(),
|
||||
inspector_collapsed_components: HashSet::new(),
|
||||
panel_nodes,
|
||||
last_mode_tab: None,
|
||||
}
|
||||
@ -184,7 +138,7 @@ impl UiState {
|
||||
)]
|
||||
fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) {
|
||||
apply_editor_theme(ctx);
|
||||
let selected_before_ui: Vec<Entity> = self.selected_entities.iter().collect();
|
||||
let selected_before_ui = world.resource::<SelectedEntity>().0;
|
||||
|
||||
let mode = *world.resource::<State<EditorMode>>().get();
|
||||
if self.last_mode_tab != Some(mode) {
|
||||
@ -215,15 +169,9 @@ impl UiState {
|
||||
&self.selected_entities,
|
||||
&mut self.dock_state,
|
||||
&mut self.panel_nodes,
|
||||
self.viewport_rect,
|
||||
);
|
||||
|
||||
editor_toolbar_panel(
|
||||
world,
|
||||
root_ui,
|
||||
&mut self.selected_entities,
|
||||
self.viewport_rect,
|
||||
);
|
||||
editor_toolbar_panel(world, root_ui, &mut self.selected_entities);
|
||||
status_bar_ui(world, root_ui, &self.selected_entities, mode);
|
||||
|
||||
let mut viewer = TabViewer {
|
||||
@ -285,13 +233,11 @@ impl UiState {
|
||||
world.resource_mut::<ViewportUiState>().shortcuts_open = true;
|
||||
}
|
||||
|
||||
let pending_selection = world.resource_mut::<PendingUiSelection>().0.take();
|
||||
let requested_selection = world.resource::<SelectedEntity>().0;
|
||||
reconcile_operator_selection(
|
||||
&mut self.selected_entities,
|
||||
&selected_before_ui,
|
||||
selected_before_ui,
|
||||
requested_selection,
|
||||
pending_selection.as_deref(),
|
||||
);
|
||||
let selected = self.selected_entities.as_slice().first().copied();
|
||||
world.resource_mut::<SelectedEntity>().0 = selected;
|
||||
@ -300,24 +246,16 @@ impl UiState {
|
||||
|
||||
fn reconcile_operator_selection(
|
||||
selected_entities: &mut SelectedEntities,
|
||||
selected_before_ui: &[Entity],
|
||||
selected_before_ui: Option<Entity>,
|
||||
requested_selection: Option<Entity>,
|
||||
pending_selection: Option<&[Entity]>,
|
||||
) {
|
||||
if selected_entities.as_slice() != selected_before_ui {
|
||||
let selected_after_ui = selected_entities.as_slice().first().copied();
|
||||
if requested_selection == selected_before_ui || selected_after_ui != selected_before_ui {
|
||||
return;
|
||||
}
|
||||
if let Some(entities) = pending_selection {
|
||||
replace_selected_entities(selected_entities, entities);
|
||||
return;
|
||||
}
|
||||
if requested_selection == selected_before_ui.first().copied() {
|
||||
return;
|
||||
}
|
||||
if let Some(entity) = requested_selection {
|
||||
replace_selected_entities(selected_entities, &[entity]);
|
||||
} else {
|
||||
selected_entities.clear();
|
||||
if let Some(entity) = requested_selection {
|
||||
selected_entities.select_replace(entity);
|
||||
}
|
||||
}
|
||||
|
||||
@ -372,7 +310,6 @@ impl Plugin for EditorUiPlugin {
|
||||
.init_resource::<material_library::MaterialLibraryState>()
|
||||
.init_resource::<material_library::MaterialLibraryCatalog>()
|
||||
.init_resource::<DockTabRequest>()
|
||||
.init_resource::<PendingUiSelection>()
|
||||
.init_resource::<inspector::InspectorClipboard>()
|
||||
.init_resource::<inspector::InspectorPanelState>()
|
||||
.init_resource::<animation_inspector::AnimationInspectorState>()
|
||||
@ -539,7 +476,7 @@ mod tests {
|
||||
let mut selected = SelectedEntities::default();
|
||||
selected.select_replace(before);
|
||||
|
||||
reconcile_operator_selection(&mut selected, &[before], Some(spawned), None);
|
||||
reconcile_operator_selection(&mut selected, Some(before), Some(spawned));
|
||||
|
||||
assert_eq!(selected.as_slice(), &[spawned]);
|
||||
}
|
||||
@ -552,62 +489,8 @@ mod tests {
|
||||
let mut selected = SelectedEntities::default();
|
||||
selected.select_replace(clicked);
|
||||
|
||||
reconcile_operator_selection(
|
||||
&mut selected,
|
||||
&[before],
|
||||
Some(requested),
|
||||
Some(&[requested, Entity::from_bits(4)]),
|
||||
);
|
||||
reconcile_operator_selection(&mut selected, Some(before), Some(requested));
|
||||
|
||||
assert_eq!(selected.as_slice(), &[clicked]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_ui_secondary_selection_change_beats_pending_operator_selection() {
|
||||
let a = Entity::from_bits(1);
|
||||
let b = Entity::from_bits(2);
|
||||
let c = Entity::from_bits(3);
|
||||
let d = Entity::from_bits(4);
|
||||
let e = Entity::from_bits(5);
|
||||
let mut selected = SelectedEntities::default();
|
||||
selected.select_replace(a);
|
||||
selected.select_maybe_add(c, true);
|
||||
|
||||
reconcile_operator_selection(&mut selected, &[a, b], Some(d), Some(&[d, e]));
|
||||
|
||||
assert_eq!(selected.as_slice(), &[a, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_scope_multi_selection_request_preserves_all_entities() {
|
||||
let before = Entity::from_bits(1);
|
||||
let first_duplicate = Entity::from_bits(2);
|
||||
let second_duplicate = Entity::from_bits(3);
|
||||
let mut world = World::new();
|
||||
world.init_resource::<SelectedEntity>();
|
||||
world.init_resource::<PendingUiSelection>();
|
||||
world.resource_mut::<SelectedEntity>().0 = Some(before);
|
||||
|
||||
let mut ui_state = UiState::default_layout();
|
||||
ui_state.selected_entities.select_replace(before);
|
||||
world.insert_resource(ui_state);
|
||||
|
||||
world.resource_scope::<UiState, _>(|world, mut ui_state| {
|
||||
assert!(!world.contains_resource::<UiState>());
|
||||
request_ui_selection(world, &[first_duplicate, second_duplicate]);
|
||||
|
||||
let pending = world.resource_mut::<PendingUiSelection>().0.take();
|
||||
reconcile_operator_selection(
|
||||
&mut ui_state.selected_entities,
|
||||
&[before],
|
||||
world.resource::<SelectedEntity>().0,
|
||||
pending.as_deref(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ui_state.selected_entities.as_slice(),
|
||||
&[first_duplicate, second_duplicate]
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -858,16 +858,10 @@ pub enum NavigationActorType {
|
||||
Link,
|
||||
}
|
||||
|
||||
pub fn spawn_navigation_actor(
|
||||
world: &mut World,
|
||||
actor_type: NavigationActorType,
|
||||
viewport_rect: egui::Rect,
|
||||
) -> Entity {
|
||||
pub fn spawn_navigation_actor(world: &mut World, actor_type: NavigationActorType) -> Entity {
|
||||
let rect = world.resource::<crate::ui::UiState>().viewport_rect;
|
||||
let settings = world.resource::<ViewportSettings>().clone();
|
||||
let translation = snap_translation(
|
||||
editor_spawn_ground_position(world, viewport_rect),
|
||||
&settings,
|
||||
);
|
||||
let translation = snap_translation(editor_spawn_ground_position(world, rect), &settings);
|
||||
let (name, bounds, obstacle, area, link) = match actor_type {
|
||||
NavigationActorType::Bounds => (
|
||||
"Navigation Bounds",
|
||||
@ -1025,49 +1019,8 @@ pub fn sync_navigation_state(world: &mut World) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history::EditorHistory;
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::ui::{PendingUiSelection, UiState};
|
||||
use scene::navigation::NavigationGeometryInput;
|
||||
use shared::{ActorKind, LevelObject};
|
||||
|
||||
#[test]
|
||||
fn navigation_spawn_works_while_ui_state_is_scoped_out_of_world() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EditorHistory>();
|
||||
world.init_resource::<SceneIo>();
|
||||
world.init_resource::<SelectedEntity>();
|
||||
world.init_resource::<PendingUiSelection>();
|
||||
world.init_resource::<ViewportSettings>();
|
||||
|
||||
let mut ui_state = UiState::default_layout();
|
||||
ui_state.viewport_rect =
|
||||
egui::Rect::from_min_size(egui::pos2(100.0, 80.0), egui::vec2(1280.0, 720.0));
|
||||
world.insert_resource(ui_state);
|
||||
|
||||
let entity = world.resource_scope::<UiState, _>(|world, mut ui_state| {
|
||||
assert!(!world.contains_resource::<UiState>());
|
||||
let entity =
|
||||
spawn_navigation_actor(world, NavigationActorType::Bounds, ui_state.viewport_rect);
|
||||
let pending = world.resource_mut::<PendingUiSelection>().0.take();
|
||||
super::super::reconcile_operator_selection(
|
||||
&mut ui_state.selected_entities,
|
||||
&[],
|
||||
world.resource::<SelectedEntity>().0,
|
||||
pending.as_deref(),
|
||||
);
|
||||
assert_eq!(ui_state.selected_entities.as_slice(), &[entity]);
|
||||
entity
|
||||
});
|
||||
|
||||
assert!(world.contains_resource::<UiState>());
|
||||
assert!(world.get::<LevelObject>(entity).is_some());
|
||||
assert!(world.get::<NavigationBounds>(entity).is_some());
|
||||
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
|
||||
assert_eq!(world.resource::<SelectedEntity>().0, Some(entity));
|
||||
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn world_affine_uses_current_authored_parent_chain() {
|
||||
|
||||
@ -8,6 +8,7 @@ use egui_phosphor_icons::icons;
|
||||
use crate::history::spawn_with_history;
|
||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||
use crate::state::{EditorMode, PlayPaused, PlayPossession};
|
||||
use crate::ui::UiState;
|
||||
use crate::viewport::brush_tool::start_draw_brush_tool;
|
||||
use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings};
|
||||
use shared::PrimitiveShape;
|
||||
@ -29,21 +30,15 @@ pub fn editor_toolbar_panel(
|
||||
world: &mut World,
|
||||
root_ui: &mut egui::Ui,
|
||||
selected: &mut SelectedEntities,
|
||||
viewport_rect: egui::Rect,
|
||||
) {
|
||||
egui::Panel::top("editor_toolbar")
|
||||
.exact_size(TOOLBAR_HEIGHT)
|
||||
.show_inside(root_ui, |ui| {
|
||||
toolbar_ui(world, ui, selected, viewport_rect);
|
||||
toolbar_ui(world, ui, selected);
|
||||
});
|
||||
}
|
||||
|
||||
pub fn toolbar_ui(
|
||||
world: &mut World,
|
||||
ui: &mut egui::Ui,
|
||||
selected: &mut SelectedEntities,
|
||||
viewport_rect: egui::Rect,
|
||||
) {
|
||||
pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui, selected: &mut SelectedEntities) {
|
||||
let (toolbar_rect, _) = ui.allocate_exact_size(
|
||||
egui::vec2(ui.available_width(), TOOLBAR_HEIGHT - 2.0),
|
||||
egui::Sense::hover(),
|
||||
@ -70,7 +65,7 @@ pub fn toolbar_ui(
|
||||
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|
||||
);
|
||||
left_ui.spacing_mut().item_spacing.x = 6.0;
|
||||
left_toolbar(world, &mut left_ui, viewport_rect);
|
||||
left_toolbar(world, &mut left_ui);
|
||||
|
||||
let mut transport_ui = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
@ -105,7 +100,7 @@ pub fn toolbar_ui(
|
||||
);
|
||||
}
|
||||
|
||||
fn left_toolbar(world: &mut World, ui: &mut egui::Ui, viewport_rect: egui::Rect) {
|
||||
fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
||||
brand_mark_frame().show(ui, |ui| {
|
||||
ui.set_min_width(118.0);
|
||||
ui.set_min_height(30.0);
|
||||
@ -131,16 +126,16 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui, viewport_rect: egui::Rect)
|
||||
toolbar_group_frame().show(ui, |ui| {
|
||||
panel_toolbar_row(ui, |ui| {
|
||||
if icon_button(ui, icons::CUBE, "Spawn cube").clicked() {
|
||||
spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube", viewport_rect);
|
||||
spawn_primitive_at_camera(world, PrimitiveShape::Box, "Cube");
|
||||
}
|
||||
if icon_button(ui, icons::MOUNTAINS, "Spawn ramp").clicked() {
|
||||
spawn_primitive_at_camera(world, PrimitiveShape::Ramp, "Ramp", viewport_rect);
|
||||
spawn_primitive_at_camera(world, PrimitiveShape::Ramp, "Ramp");
|
||||
}
|
||||
if icon_button(ui, icons::SPHERE, "Spawn sphere").clicked() {
|
||||
spawn_primitive_at_camera(world, PrimitiveShape::Sphere, "Sphere", viewport_rect);
|
||||
spawn_primitive_at_camera(world, PrimitiveShape::Sphere, "Sphere");
|
||||
}
|
||||
if icon_button(ui, icons::LIGHTBULB, "Spawn point light").clicked() {
|
||||
let pos = camera_ground_spawn(world, viewport_rect);
|
||||
let pos = camera_ground_spawn(world);
|
||||
spawn_with_history(world, light_snapshot("Point Light", pos));
|
||||
}
|
||||
if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() {
|
||||
@ -162,7 +157,6 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui, viewport_rect: egui::Rect)
|
||||
super::navigation_inspector::spawn_navigation_actor(
|
||||
world,
|
||||
super::navigation_inspector::NavigationActorType::Bounds,
|
||||
viewport_rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -238,68 +232,14 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
fn camera_ground_spawn(world: &mut World, viewport_rect: egui::Rect) -> Vec3 {
|
||||
fn camera_ground_spawn(world: &mut World) -> Vec3 {
|
||||
let rect = world.resource::<UiState>().viewport_rect;
|
||||
let settings = world.resource::<ViewportSettings>().clone();
|
||||
let pos = editor_spawn_ground_position(world, viewport_rect);
|
||||
let pos = editor_spawn_ground_position(world, rect);
|
||||
snap_translation(pos, &settings)
|
||||
}
|
||||
|
||||
fn spawn_primitive_at_camera(
|
||||
world: &mut World,
|
||||
shape: PrimitiveShape,
|
||||
name: &str,
|
||||
viewport_rect: egui::Rect,
|
||||
) -> Entity {
|
||||
let pos = camera_ground_spawn(world, viewport_rect);
|
||||
spawn_with_history(world, primitive_snapshot(name, shape, pos))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::history::EditorHistory;
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::selection::SelectedEntity;
|
||||
use crate::ui::{PendingUiSelection, UiState};
|
||||
use shared::{LevelObject, Primitive};
|
||||
|
||||
#[test]
|
||||
fn primitive_spawn_works_while_ui_state_is_scoped_out_of_world() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EditorHistory>();
|
||||
world.init_resource::<SceneIo>();
|
||||
world.init_resource::<SelectedEntity>();
|
||||
world.init_resource::<PendingUiSelection>();
|
||||
world.init_resource::<ViewportSettings>();
|
||||
|
||||
let mut ui_state = UiState::default_layout();
|
||||
ui_state.viewport_rect =
|
||||
egui::Rect::from_min_size(egui::pos2(100.0, 80.0), egui::vec2(1280.0, 720.0));
|
||||
world.insert_resource(ui_state);
|
||||
|
||||
let entity = world.resource_scope::<UiState, _>(|world, mut ui_state| {
|
||||
assert!(!world.contains_resource::<UiState>());
|
||||
let entity = spawn_primitive_at_camera(
|
||||
world,
|
||||
PrimitiveShape::Box,
|
||||
"Cube",
|
||||
ui_state.viewport_rect,
|
||||
);
|
||||
let pending = world.resource_mut::<PendingUiSelection>().0.take();
|
||||
super::super::reconcile_operator_selection(
|
||||
&mut ui_state.selected_entities,
|
||||
&[],
|
||||
world.resource::<SelectedEntity>().0,
|
||||
pending.as_deref(),
|
||||
);
|
||||
assert_eq!(ui_state.selected_entities.as_slice(), &[entity]);
|
||||
entity
|
||||
});
|
||||
|
||||
assert!(world.contains_resource::<UiState>());
|
||||
assert!(world.get::<LevelObject>(entity).is_some());
|
||||
assert!(world.get::<Primitive>(entity).is_some());
|
||||
assert_eq!(world.resource::<SelectedEntity>().0, Some(entity));
|
||||
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
|
||||
}
|
||||
fn spawn_primitive_at_camera(world: &mut World, shape: PrimitiveShape, name: &str) {
|
||||
let pos = camera_ground_spawn(world);
|
||||
spawn_with_history(world, primitive_snapshot(name, shape, pos));
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ use bevy::app::PluginGroupBuilder;
|
||||
use bevy::asset::AssetPlugin;
|
||||
use bevy::log::{LogPlugin, DEFAULT_FILTER};
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{CompositeAlphaMode, ExitCondition, PresentMode, WindowMode, WindowPlugin};
|
||||
use bevy::window::{CompositeAlphaMode, PresentMode, WindowMode, WindowPlugin};
|
||||
|
||||
/// Runtime switch for the HDR camera pass.
|
||||
///
|
||||
@ -40,27 +40,6 @@ fn find_assets_directory(start: Option<PathBuf>) -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn default_plugins(title: impl Into<String>) -> PluginGroupBuilder {
|
||||
configured_plugins(WindowPlugin {
|
||||
primary_window: Some(primary_window(title)),
|
||||
..default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Default plugins for the editor, whose shutdown coordinator owns window-close handling.
|
||||
pub fn editor_plugins(title: impl Into<String>) -> PluginGroupBuilder {
|
||||
configured_plugins(editor_window_plugin(title))
|
||||
}
|
||||
|
||||
fn editor_window_plugin(title: impl Into<String>) -> WindowPlugin {
|
||||
WindowPlugin {
|
||||
primary_window: Some(primary_window(title)),
|
||||
exit_condition: ExitCondition::DontExit,
|
||||
close_when_requested: false,
|
||||
..default()
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_plugins(window_plugin: WindowPlugin) -> PluginGroupBuilder {
|
||||
DefaultPlugins
|
||||
.set(LogPlugin {
|
||||
// Blacksite intentionally replaces Bevy's `.scn.ron` loader with a
|
||||
@ -69,7 +48,10 @@ fn configured_plugins(window_plugin: WindowPlugin) -> PluginGroupBuilder {
|
||||
filter: format!("{DEFAULT_FILTER}bevy_asset::server::loaders=error"),
|
||||
..default()
|
||||
})
|
||||
.set(window_plugin)
|
||||
.set(WindowPlugin {
|
||||
primary_window: Some(primary_window(title)),
|
||||
..default()
|
||||
})
|
||||
.set(AssetPlugin {
|
||||
file_path: resolve_assets_directory(),
|
||||
..default()
|
||||
@ -104,8 +86,8 @@ pub fn hdr_enabled_from_env() -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{editor_window_plugin, primary_window, resolve_assets_directory};
|
||||
use bevy::window::{CompositeAlphaMode, ExitCondition};
|
||||
use super::{primary_window, resolve_assets_directory};
|
||||
use bevy::window::CompositeAlphaMode;
|
||||
|
||||
#[test]
|
||||
fn primary_window_is_opaque() {
|
||||
@ -115,14 +97,6 @@ mod tests {
|
||||
assert_eq!(window.composite_alpha_mode, CompositeAlphaMode::Opaque);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_window_defers_close_and_exit_to_the_editor() {
|
||||
let plugin = editor_window_plugin("test");
|
||||
|
||||
assert!(!plugin.close_when_requested);
|
||||
assert!(matches!(plugin.exit_condition, ExitCondition::DontExit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_assets_directory_finds_workspace_assets() {
|
||||
let cwd = std::env::current_dir().expect("cwd");
|
||||
|
||||
@ -56,7 +56,6 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
||||
| [0039](adr/0039-inline-height-grid-terrain-foundation.md) | Inline authored height grids with deterministic runtime-only chunk hydration |
|
||||
| [0040](adr/0040-terrain-material-layer-weights.md) | Four-channel terrain material layers, compact normalized weights, and raster transport |
|
||||
| [0041](adr/0041-transactional-editor-physics-placement.md) | Paused editor physics ownership and transactional gravity placement |
|
||||
| [0042](adr/0042-guarded-editor-shutdown-and-document-savepoints.md) | Guarded native editor exit and canonical per-document clean checkpoints |
|
||||
|
||||
## Editor framework
|
||||
|
||||
@ -95,8 +94,6 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
||||
| [editor/evaluations/navigation-authoring/](editor/evaluations/navigation-authoring/) | Live screenshots and acceptance results for navigation authoring and runtime parity |
|
||||
| [editor/evaluations/operator-invariants/](editor/evaluations/operator-invariants/) | Source acceptance results for production operator lifecycle, rollback, cleanup, and undo/redo invariants |
|
||||
| [editor/evaluations/sample-regression-pack/](editor/evaluations/sample-regression-pack/) | Exact-implementation source and native acceptance evidence for the editor sample regression pack |
|
||||
| [editor/evaluations/guarded-shutdown-savepoints/](editor/evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
|
||||
| [editor/evaluations/scoped-ui-actions/](editor/evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
|
||||
| [editor/evaluations/production-readiness/](editor/evaluations/production-readiness/) | Current versioned production gate, evidence matrix, candidate commands, soak protocol, and independent sign-off requirements |
|
||||
|
||||
## Working plans (not canonical long-term)
|
||||
@ -122,7 +119,6 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
|
||||
| `terrain_material_layers_*.plan.md` | Terrain shared-material layers, normalized weights, blended hydration, and modal painting |
|
||||
| `operator_invariants_completion_*.plan.md` | Production operator dispatch, interruption, rollback, cleanup, and undo/redo acceptance |
|
||||
| `editor_sample_regression_pack_*.plan.md` | Five-area sample manifest, editor catalog, deterministic validation, and native regression acceptance |
|
||||
| `guarded_shutdown_savepoints_*.plan.md` | Native close coordination, asynchronous Save All, and canonical history clean points |
|
||||
|
||||
## Crate responsibilities (quick reference)
|
||||
|
||||
|
||||
@ -34,19 +34,12 @@ assets, mark a scene clean, enter source control, or silently replace authored w
|
||||
or Discard removes that scene's recovery generations. Save Recovery Copy As writes the snapshot
|
||||
to a separate transactional scene file without changing the active scene or recovery lifecycle.
|
||||
- A successful manual save retires recovery generations for that scene.
|
||||
- A successful load or manual save establishes that tab's canonical authored-content checkpoint.
|
||||
Undo and redo compare the current stable authored projection with this checkpoint to derive dirty
|
||||
state. Failed writes, cancelled Save As, and recovery restores do not advance it.
|
||||
- Native editor exit is authorized only after all dirty scene tabs save successfully or the user
|
||||
explicitly discards them. See [ADR 0042](0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Interrupted manual writes retain the previous complete destination on filesystems with atomic
|
||||
same-directory rename semantics.
|
||||
- Recovery is machine-local and does not pollute builds or source control.
|
||||
- Undoing exactly to the last loaded or saved authored state clears the dirty marker without
|
||||
weakening recovery after direct edits or failed saves.
|
||||
- Autosave still performs authored serialization and hydration rebuild work. Performance
|
||||
instrumentation and background snapshotting remain follow-up work under the production roadmap.
|
||||
- Unsaved new scenes do not yet have a stable recovery identity; they require a later session-ID
|
||||
|
||||
@ -20,10 +20,8 @@ untrusted serialized ECS state.
|
||||
- Session schema v1 allowlists metadata only: project root, active scene path, saved dock-layout
|
||||
text, hierarchy expansion paths, non-destructive panel visibility, and finite camera bookmark
|
||||
transforms.
|
||||
- Write the session transactionally. Startup immediately writes `clean_shutdown: false`; only an
|
||||
editor-shutdown-coordinator-authorized `AppExit` writes the final document with
|
||||
`clean_shutdown: true`. Direct `AppExit`, forced termination, and process failure retain the
|
||||
abnormal marker. See [ADR 0042](0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||
- Write the session transactionally. Startup immediately writes `clean_shutdown: false`; normal
|
||||
`AppExit` writes the final document with `clean_shutdown: true`.
|
||||
- A clean prior session may restore its active scene and non-destructive UI state automatically.
|
||||
An abnormal prior session starts from the normal safe scene and requires explicit confirmation
|
||||
before opening the prior scene.
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
# ADR 0042: Guarded Editor Shutdown And Document Savepoints
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Bevy's default native-window policy closes a requested window and exits after the final window is
|
||||
gone. That policy bypasses Blacksite's scene-tab and project-switch confirmations, so a compositor
|
||||
close request could silently discard dirty authored work. Reading `WindowCloseRequested` in another
|
||||
system is insufficient because Bevy's default close system has its own message cursor and still
|
||||
despawns the window.
|
||||
|
||||
Scene dirtiness was also independent of editor history. Every undo or redo marked the active scene
|
||||
dirty, even when undo restored the exact state that had last loaded or saved. Undo depth alone is
|
||||
not a valid savepoint: editor commands contain runtime entity IDs and are intentionally cleared when
|
||||
tabs switch, while saves may establish a clean point in the middle of a timeline.
|
||||
|
||||
## Decision
|
||||
|
||||
- The full editor disables Bevy's automatic close-on-request behavior. The game and Project Browser
|
||||
keep their existing native-window policy.
|
||||
- One editor shutdown coordinator owns native window close, **File > Quit**, project switching, and
|
||||
programmatic editor-exit requests. It coalesces duplicate requests and is the only normal path
|
||||
authorized to emit `AppExit`.
|
||||
- Final authorization runs in a dedicated main schedule after Bevy's `Last` schedule. The guard
|
||||
rechecks dirty scene state after every editor authoring system, then clean-session persistence
|
||||
runs after that finalizer in the same post-`Last` schedule.
|
||||
- A clean editor exits immediately. Dirty scene tabs enter one non-blocking native decision with
|
||||
explicit **Save All**, **Discard**, and **Cancel** actions. Save completion, including an untitled
|
||||
tab's Save As picker, is applied on the main thread. The coordinator exits only after every dirty
|
||||
tab saves or the user explicitly discards; cancel or failure retains the live session.
|
||||
- Clean-session persistence accepts only an exit authorized by the coordinator. Crashes, forced
|
||||
termination, and unguarded `AppExit` retain the abnormal-session marker.
|
||||
- Every scene tab owns a canonical authored-content checkpoint established by a successful load or
|
||||
save. The projection uses stable actor IDs, stable parent actor IDs, sorted component identities,
|
||||
and scene composition; it excludes transient Bevy entity numbers and runtime hydration.
|
||||
- A committed edit marks the active tab dirty. Successful undo or redo compares the current
|
||||
canonical authored projection with that tab's checkpoint. Equality clears the dirty marker;
|
||||
divergence sets it. Failed saves and recovery restores do not advance the checkpoint.
|
||||
- Editor command stacks remain active-document runtime state and are still cleared on tab switches.
|
||||
Checkpoint ownership belongs to the document tab, so dirty truth survives those clears.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Native close can no longer silently discard authored scene tabs, and the editor remains responsive
|
||||
while confirmation or Save As dialogs are open.
|
||||
- A normal exit requires an explicit editor-owned authorization. New code must request shutdown
|
||||
through the coordinator instead of writing `AppExit` directly.
|
||||
- Undoing to the last saved authored state clears title and tab dirty indicators, including after a
|
||||
save at nonzero history depth. Redo or a divergent branch marks the document dirty again.
|
||||
- Reconciliation serializes the authored projection after undo and redo. This is more work than a
|
||||
depth comparison, but it is bounded to explicit history navigation and remains correct across
|
||||
direct non-history repairs, entity respawns, and tab switches.
|
||||
- Operating-system process kill and power loss cannot be confirmed; recovery and abnormal-session
|
||||
handling remain the safety boundary for those cases.
|
||||
@ -42,8 +42,6 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
||||
| [evaluations/navigation-authoring/](evaluations/navigation-authoring/) | Live screenshots and verification record for navigation authoring and runtime parity |
|
||||
| [evaluations/operator-invariants/](evaluations/operator-invariants/) | Source acceptance record for production operator lifecycle, rollback, cleanup, and undo/redo invariants |
|
||||
| [evaluations/sample-regression-pack/](evaluations/sample-regression-pack/) | Exact-implementation source and native acceptance evidence for the five-area editor regression pack |
|
||||
| [evaluations/guarded-shutdown-savepoints/](evaluations/guarded-shutdown-savepoints/) | Source and native acceptance evidence for guarded editor exit and per-document clean checkpoints |
|
||||
| [evaluations/scoped-ui-actions/](evaluations/scoped-ui-actions/) | Exact-implementation source and native acceptance evidence for actions invoked during scoped egui rendering |
|
||||
| [evaluations/production-readiness/](evaluations/production-readiness/) | Current #50 release-candidate matrix and required clean-checkout, soak, performance, limitation, and independent-signoff evidence |
|
||||
|
||||
## Subsystems (code → doc)
|
||||
@ -113,10 +111,6 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
|
||||
components on load and component transactions, and does not prohibit compatible composition such
|
||||
as renderer plus light. Component validation owns requirements and geometry-source conflicts.
|
||||
- **Scene persistence and recovery** use transactional same-directory writes for authored scenes and bounded user-local recovery snapshots for dirty saved scenes. Recovery runs every 120 seconds by default, keeps five generations, never marks the scene clean, and appears as a status/File-menu restore or discard action when newer than the authored file. Restore leaves the original scene dirty until manually saved. See [ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
|
||||
- **Guarded shutdown and clean savepoints** route native close, **File > Quit**, project switching,
|
||||
and programmatic exit through one non-blocking Save All / Discard / Cancel coordinator. Each tab's
|
||||
dirty marker is reconciled against its canonical last-loaded-or-saved authored projection after
|
||||
undo/redo. See [ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||
- **Collaborative file safety** records exact content revisions for loaded scenes, prefab source operations, editable materials, and Project Settings, then rechecks them immediately before atomic publication. External changes, create races, read-only files, and optional ownership locks keep the existing bytes intact and open Reload/Compare Metadata/Save As/Cancel recovery. Git status is asynchronous, observational, and absent without noise outside a repository. See [collaborative-file-safety.md](collaborative-file-safety.md) and [ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
|
||||
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, gameplay markers, volumes, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Spot lights show inner/outer cones and cameras show near/far frusta. Viewport options independently gate visualizer categories and include actor icon and transform-gizmo size sliders.
|
||||
- **Clean game-view overlay** (`G`) hides editor chrome, grid, selection outlines, transform gizmos, actor root icons, visualizers, and selectable proxies without changing camera ownership; `Ctrl+G` toggles grid.
|
||||
|
||||
@ -87,20 +87,11 @@ or Cancel. Generated artifacts remain under their subsystem-owned regeneration p
|
||||
[collaborative-file-safety.md](collaborative-file-safety.md) and
|
||||
[ADR 0037](../adr/0037-collaborative-authored-file-safety.md).
|
||||
|
||||
`project/shutdown.rs` owns native close, **File > Quit**, project switching, and programmatic editor
|
||||
exit. The editor launch configuration leaves native close requests open; the coordinator coalesces
|
||||
them, retries the shared native-dialog broker when busy, and authorizes `AppExit` only after clean
|
||||
state, successful Save All, or explicit Discard. Scene I/O owns the asynchronous per-tab save
|
||||
transaction and reports completion back to the coordinator. Final authorization and clean-session
|
||||
persistence run in an editor schedule inserted after Bevy's `Last`, so material drops, brush/gizmo
|
||||
finalizers, and every other authoring system finish before the last dirty-state check. See
|
||||
[ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||
|
||||
`project/session.rs` owns the independent versioned restart document under the user state
|
||||
directory. It snapshots allowlisted paths, panel visibility, dock/hierarchy metadata, and camera
|
||||
bookmarks, writes a running marker at startup, and records clean state only for a coordinator-
|
||||
authorized `AppExit`. Abnormal sessions do not auto-open the prior scene; the explicit safe-resume
|
||||
prompt is the only path back. See [ADR 0024](../adr/0024-versioned-editor-session-state.md).
|
||||
bookmarks, writes a running marker at startup, and records clean `AppExit`. Abnormal sessions do
|
||||
not auto-open the prior scene; the explicit safe-resume prompt is the only path back. See
|
||||
[ADR 0024](../adr/0024-versioned-editor-session-state.md).
|
||||
|
||||
`project/diagnostics_bundle.rs` exports a separate, transactional support report from an explicit
|
||||
metadata allowlist. It summarizes build/renderer identity, project paths, dirty state, aggregate
|
||||
@ -209,7 +200,7 @@ the Edit-to-Play boundary restore the complete runtime snapshot. See
|
||||
1. User edits entities with `LevelObject` + reflectable components from `shared`.
|
||||
2. `EditorOnly` entities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities.
|
||||
3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized.
|
||||
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through `scene::document::SceneDocument`. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots, canonical clean checkpoints, and independent dirty/recovery state. Undo/redo reconciles stable authored content rather than transient ECS entity IDs. `SceneComposition` resources reference validated project-relative subscenes by stable IDs, and runtime-only `ComposedSceneMember` ownership prevents child actors from being flattened into the owner save. See [multi-scene-composition.md](multi-scene-composition.md), ADR 0026, and [ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through `scene::document::SceneDocument`. The active tab is materialized in the ECS world; inactive tabs retain normalized authored snapshots and independent dirty/recovery state. `SceneComposition` resources reference validated project-relative subscenes by stable IDs, and runtime-only `ComposedSceneMember` ownership prevents child actors from being flattened into the owner save. See [multi-scene-composition.md](multi-scene-composition.md) and ADR 0026.
|
||||
5. Hydration systems in `game` spawn meshes, colliders, lights, static mesh renderer parts, and
|
||||
dedicated skinned-model hierarchies at runtime. Level-object roots are initialized with Bevy
|
||||
visibility hierarchy components before generated children are attached, so authoring
|
||||
|
||||
@ -12,8 +12,6 @@ Living checklist for the production editor program ([ADR 0012](../adr/0012-zero-
|
||||
| Hydrated components in committed `.scn.ron` | CI | `scene::validate_scene_authoring_only` + `repo_editor_scene_has_no_hydrated_components` |
|
||||
| Production mutation without commit/cancel/failure/undo invariants | Done | Shared operator harness plus typed history projections cover palette, asset, brush, terrain, physics, grouping, lighting, material-drop, and transform paths; see [evaluation](evaluations/operator-invariants/) |
|
||||
| Representative editor coverage can silently disappear | Done | Versioned five-area manifest, typed scene gate, `validate-samples`, and native protocol; see [sample pack](sample-regression-pack.md) |
|
||||
| Dirty editor exit can bypass an explicit decision, or transient entity IDs define document cleanliness | Done | Guarded shutdown and canonical authored-content checkpoints pass source validation and all seven native workflows at source implementation `4b33f32`, including dirty untitled Save All through Save As. See the [evaluation](evaluations/guarded-shutdown-savepoints/). |
|
||||
| Scoped UI callbacks depend on temporarily unavailable host state or lose direct selection precedence | Done | Explicit selection/tab requests, panel-owned state, captured viewport data, and full-slice reconciliation pass six scoped regressions, independent re-review, and native toolbar/Inspector/diagnostics acceptance at `9e23ae7`; see the [evaluation](evaluations/scoped-ui-actions/). |
|
||||
| Dual FBX thumbnail ad-hoc path (parallel to unified pipeline) | Partial | Phase 5 `assets/thumbnails/` refactor |
|
||||
| `failed_keys` thumbnail cache without retry API | Partial | `asset_thumbnails.rs`; Phase 5 `ThumbnailState` |
|
||||
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
# Guarded Shutdown And Clean Savepoints Evaluation
|
||||
|
||||
**Date:** 2026-07-13
|
||||
|
||||
**Issue:** [Gitea #55](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/55)
|
||||
|
||||
**Branch:** `codex/guarded-shutdown-savepoints`
|
||||
|
||||
**Implementation commit:** `4b33f3235761fd0dd3daf219e5bbbaee884eb626`
|
||||
|
||||
**Release-candidate commit:** Not nominated
|
||||
|
||||
**Implementation validation:** **Pass**
|
||||
|
||||
**Native acceptance:** **Pass**
|
||||
|
||||
This record covers editor-owned shutdown authorization and per-document authored-content clean
|
||||
checkpoints. The permanent architecture contract lives in
|
||||
[ADR 0042](../../../adr/0042-guarded-editor-shutdown-and-document-savepoints.md). The automated
|
||||
results below apply to the exact implementation commit. Native rows exercise the same shutdown
|
||||
implementation after the unrelated toolbar fix disclosed below. Neither replaces the clean-checkout,
|
||||
soak, or independent-signoff requirements for a future release candidate.
|
||||
|
||||
## Automated Verification
|
||||
|
||||
| Command or suite | Result | Evidence |
|
||||
|------------------|--------|----------|
|
||||
| `cargo fmt --all -- --check` | Pass | Formatting check completed without changes. |
|
||||
| `git diff --check` | Pass | No whitespace errors. |
|
||||
| `cargo clippy --workspace --all-targets -- -D warnings` | Pass | Workspace and all targets completed with warnings denied. |
|
||||
| `cargo test --workspace --all-targets --no-fail-fast` | Pass | All workspace targets passed; the editor crate reported 282 passing tests. |
|
||||
| Guarded shutdown regressions | Pass | Native close, menu/programmatic exit, cancel/discard, broker contention, late-authoring mutation, and secondary-window behavior are covered by 13 focused tests. |
|
||||
| Save All regressions | Pass | Named and untitled tab sequencing, cancellation, conflict retention, duplicate destination rejection, and active-tab restoration are covered by six focused tests. |
|
||||
| Project-switch regressions | Pass | Three focused tests cover guarded handoff, cancellation, and browser-spawn failure. |
|
||||
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed. |
|
||||
|
||||
## Native Acceptance
|
||||
|
||||
The guarded-shutdown implementation under test is the source commit named above. The native debug
|
||||
editor was rebuilt at `9e23ae731f95dc351ecc744d6ca61f6bc4467317` after the independently scoped
|
||||
[UI action-safety fix](../scoped-ui-actions/) ([Gitea #57](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/57))
|
||||
made the untitled-scene toolbar workflow reachable, then launched as
|
||||
`target/debug/editor --project .`. Each row records an observed workflow rather than inferring
|
||||
behavior from the automated suite.
|
||||
|
||||
| Workflow | Result | Evidence |
|
||||
|----------|--------|----------|
|
||||
| Undo back to the clean checkpoint | Pass | A scene mutation added the dirty marker; undo restored the canonical authored projection and cleared the marker. |
|
||||
| Native window close, then **Cancel** | Pass | The dirty-document decision appeared; Cancel retained the dirty document and kept the editor running. |
|
||||
| **File > Quit**, then **Discard** | Pass | The same guarded decision path ran and the editor process exited with code 0 without saving the dirty draft. |
|
||||
| **File > Switch Project...** guard | Pass | A dirty document was intercepted by the shared shutdown decision before project-browser handoff. |
|
||||
| Dirty named document, **Save All** | Pass | The named scene write completed before authorization and the editor then exited with code 0. |
|
||||
| Clean untitled document close | Pass | No dirty-work decision was needed and the editor exited with code 0. |
|
||||
| Dirty untitled document, **Save All**, then Save As continuation | Pass | **File > New Scene** created an untitled tab; the toolbar Cube action spawned authored content and marked it dirty. Native close followed by **Save All** opened KDE Save As in `assets/levels`; saving as `issue55_shutdown_qa.scn.ron` wrote a 1,986-byte scene and the editor exited with code 0. The QA scene was verified and removed. |
|
||||
|
||||
## Evidence Status
|
||||
|
||||

|
||||
|
||||
- Native modal capture: `/tmp/blacksite-issue55-shutdown-modal-4b33f32.png` (temporary staging;
|
||||
3426x1384 RGB PNG, 1,656,602 bytes).
|
||||
- SHA-256: `2ddb2571325e0d99f5559efd1ba3164845ffba64767833d4e5ad8d2925aedfb5`.
|
||||
- Gitea #55 native attachment: asset 36, uploaded through the issue attachment API.
|
||||
|
||||
The Gitea copy was uploaded as an ordinary issue attachment. No repository PNG or LFS raw URL is
|
||||
claimed by this evaluation.
|
||||
|
||||
## Release Use
|
||||
|
||||
The #55 source and native feature-level acceptance record is complete. A future production candidate
|
||||
must still rerun these workflows from the exact nominated commit; this record does not nominate a
|
||||
candidate or establish production readiness.
|
||||
@ -1,6 +1,6 @@
|
||||
# Production-Readiness Acceptance Matrix
|
||||
|
||||
**Matrix version:** 0.5
|
||||
**Matrix version:** 0.4
|
||||
|
||||
**Last audit:** 2026-07-13
|
||||
|
||||
@ -30,20 +30,20 @@ another commit, a dirty worktree, or an older package do not transfer to the can
|
||||
|
||||
| ID | Requirement | State | Current evidence and gap |
|
||||
|----|-------------|-------|--------------------------|
|
||||
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Partial | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49`, non-blocking native dialogs `#52`, guarded shutdown/savepoints `#55`, and scoped UI action safety `#57` have feature-level source/native acceptance. #55 passed all seven native paths at source implementation `4b33f32`; #57 passed toolbar, Inspector, diagnostics, selection, and guarded-exit paths at exact implementation `9e23ae7`. Candidate-specific end-to-end reruns remain. |
|
||||
| G1 | Project create/open/resume, scene authoring, autosave/recovery, hierarchy, prefab, and asset integrity pass | Fail | Project/recovery/session/multi-scene/prefab implementations are documented in [project launcher](../../project-launcher.md), [session recovery](../../session-recovery.md), [multi-scene composition](../../multi-scene-composition.md), and [prefab authoring](../../prefab-authoring.md). Collaborative file safety `#49` and non-blocking native dialogs `#52` passed live acceptance, but `#55` records unguarded native dirty shutdown and a missing clean-history savepoint. Candidate-specific end-to-end reruns also remain. |
|
||||
| G2 | Brush, material, terrain, physics placement, animation, audio, navigation, PIE, and build/package samples pass | Partial | The [five-area sample pack](../sample-regression-pack/) passed source and native acceptance on exact implementation commit `d52cc2e`, including Brush, Material, Terrain, Physics Placement, and Rendering. No release candidate is nominated, brush acceptance `#37` remains open, and combined animation/audio/navigation/PIE/build evidence is incomplete. Packaged testing is owner-deferred. |
|
||||
| G3 | Undo/redo/cancel invariants and helper cleanup cover every production mutation path | Partial | Source implementation and focused evidence are complete in [operator regression testing](../../operator-regression-testing.md) and the [operator-invariants evaluation](../operator-invariants/); Gitea `#33` closed at `c55f347`. A clean, exact-candidate rerun is still required for `Pass`. |
|
||||
| G4 | Representative project completes an eight-hour soak without unbounded memory/target growth or unrecoverable failure | Missing | No candidate soak log, resource timeline, failure ledger, or target-growth measurement exists. |
|
||||
| G5 | Cold start, scene open/save, asset refresh, common manipulation, and package-build budgets are documented and measured | Missing | Gitea `#34` is open; no ratified budgets or candidate measurement record exists. |
|
||||
| G6 | Headless content validation and CI are green from a clean checkout | Missing | [CI configuration](../../../../.github/workflows/ci.yml) hydrates LFS and includes both project and sample content gates. Local implementation checks have passed, but no clean-checkout candidate run is linked. The current Gitea server does not expose an Actions run endpoint for this repository. |
|
||||
| G7 | First-hour UX and recovery QA are signed off by someone other than the implementer | External | No independent sign-off exists. Gitea `#36` remains open; historical H1-H6 implementation-pass notes do not count. |
|
||||
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Partial | Sample-pack gap `#32`, guarded shutdown/savepoints `#55`, and the follow-on scoped UI action-safety defect `#57` have complete source/native feature acceptance at `d52cc2e`, source implementation `4b33f32`, and `9e23ae7` respectively; no known P0 implementation blocker remains. A consolidated candidate limitations ledger and exact-candidate rerun are still missing. Deterministic imported-asset fingerprints `#56` and property-block application `#53` are P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
|
||||
| G8 | Known limitations have severity/workaround and no P0 blocker remains | Fail | Sample-pack gap `#32` has exact-implementation source/native evidence at `d52cc2e`, but guarded native shutdown and clean history savepoints remain the open P0 `#55`. A consolidated candidate limitations ledger is also missing. Deterministic imported-asset fingerprints `#56` and property-block application `#53` are P1; dynamic deformed Solari geometry `#54` is a documented P2 limitation with Forward/raster fallback. |
|
||||
|
||||
## Deliverables
|
||||
|
||||
| ID | Deliverable | State | Evidence or next action |
|
||||
|----|-------------|-------|-------------------------|
|
||||
| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.5. |
|
||||
| D1 | Versioned acceptance matrix under `docs/editor/evaluations/` | Pass | This file, version 0.4. |
|
||||
| D2 | Release-candidate representative project and reproducible validation commands | Partial | The committed [sample regression pack](../sample-regression-pack/), commands, and exact-implementation native evidence exist. Nominate a release candidate and rerun from a clean LFS-hydrated checkout. |
|
||||
| D3 | Signed milestone comment linking evidence, limitations, and exact commit | Missing | Post only after G1-G8 pass; no candidate exists yet. |
|
||||
|
||||
@ -69,8 +69,6 @@ another commit, a dirty worktree, or an older package do not transfer to the can
|
||||
| Build/package/runtime | Implemented foundation | Deferred by owner |
|
||||
| Collaborative file safety | Implemented; `#49` closed with live external-change acceptance | Not rerun as one candidate |
|
||||
| Native file/folder dialogs | Implemented; `#52` closed with live 12-second compositor-responsiveness hold | Not rerun as one candidate |
|
||||
| Guarded shutdown and clean savepoints | Implemented; `#55` source validation and all seven native workflows passed at source implementation `4b33f32` | Feature-level acceptance complete; not rerun as one candidate |
|
||||
| Scoped UI action safety | Implemented; `#57` source validation, six focused regressions, selection precedence, and native toolbar/Inspector/diagnostics workflows passed at `9e23ae7` | Feature-level acceptance complete; not rerun as one candidate |
|
||||
|
||||
## Candidate Validation Commands
|
||||
|
||||
|
||||
@ -1,68 +0,0 @@
|
||||
# Scoped UI Action Safety Evaluation
|
||||
|
||||
**Date:** 2026-07-13
|
||||
|
||||
**Issue:** [Gitea #57](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/57)
|
||||
|
||||
**Implementation commit:** `9e23ae731f95dc351ecc744d6ca61f6bc4467317`
|
||||
|
||||
**Release-candidate commit:** Not nominated
|
||||
|
||||
**Source validation:** **Pass**
|
||||
|
||||
**Native acceptance:** **Pass**
|
||||
|
||||
This record covers synchronous editor actions invoked while the egui host scopes `UiState` out of
|
||||
the Bevy `World`. The implementation keeps callback-owned state in independently registered
|
||||
resources, passes viewport data into toolbar/navigation actions, routes cross-panel selection and
|
||||
tab requests through explicit APIs, and reconciles the complete selection slice so a direct UI
|
||||
selection takes precedence over a stale deferred request.
|
||||
|
||||
## Automated Verification
|
||||
|
||||
| Command or suite | Result | Evidence |
|
||||
|------------------|--------|----------|
|
||||
| `cargo fmt --all -- --check` | Pass | Formatting check completed without changes. |
|
||||
| `git diff --check` | Pass | No whitespace errors. |
|
||||
| `cargo clippy --workspace --all-targets -- -D warnings` | Pass | Workspace and all targets completed with warnings denied. |
|
||||
| `cargo test --workspace --all-targets --no-fail-fast` | Pass | All workspace targets passed; the editor crate reported 290 of 290 tests passing. |
|
||||
| Scoped-action regressions | Pass | Six focused regressions cover history multi-selection, diagnostics selection, Inspector collapse and asset reveal state, primitive spawn, and navigation spawn while `UiState` is scoped out. |
|
||||
| Selection precedence | Pass | Full-slice reconciliation preserves a direct primary or secondary UI selection over stale single- or multi-selection requests. |
|
||||
| Independent re-review | Pass | The post-fix review found no remaining blockers. |
|
||||
| Packaged acceptance | Deferred | Explicitly deferred by project-owner direction; no packaged result is claimed. |
|
||||
|
||||
## Native Acceptance
|
||||
|
||||
The exact rebuilt debug binary from the implementation commit was launched as
|
||||
`target/debug/editor --project .`.
|
||||
|
||||
| Workflow | Result | Evidence |
|
||||
|----------|--------|----------|
|
||||
| Toolbar authoring actions | Pass | After **File > New Scene**, Cube, Ramp, Sphere, Point Light, and Navigation Bounds all spawned. Selection, transform gizmo, viewport overlay, hierarchy, and Inspector stayed synchronized. |
|
||||
| Inspector state | Pass | Collapsing the Transform component persisted across subsequent UI rendering and selection work. |
|
||||
| Cross-panel diagnostics action | Pass | **Window > Brush Diagnostics > Select** kept the editor live and synchronized the selected actor across hierarchy, viewport, and Inspector. |
|
||||
| Guarded shutdown | Pass | The dirty native-close decision accepted **Discard** and the editor exited with code 0. |
|
||||
|
||||
## Native Evidence
|
||||
|
||||

|
||||
|
||||
- Gitea attachment asset 37: `blacksite-issue57-scoped-ui-actions.png`, 3426x1384 RGB PNG,
|
||||
890,021 bytes.
|
||||
- SHA-256: `4225aca19268e4817148507e95cdad56a5f18734fd69e2fe5702df1dc304142e`.
|
||||
|
||||

|
||||
|
||||
- Gitea attachment asset 38: `blacksite-issue57-diagnostics-select.png`, 3426x1384 RGB PNG,
|
||||
1,418,849 bytes.
|
||||
- SHA-256: `40422be59766a3e61005973c315d0fd6f663dd2d227aa92055fa2aa2e91320c3`.
|
||||
|
||||
Both images were uploaded as ordinary Gitea issue attachments; this record does not use repository
|
||||
raw or LFS pointer URLs.
|
||||
|
||||
## Release Use
|
||||
|
||||
The #57 source and native feature-level acceptance record is complete. A future production candidate
|
||||
must still rerun representative toolbar, Inspector, diagnostics, selection, and guarded-exit paths
|
||||
from the exact nominated commit. This record does not nominate a candidate or establish production
|
||||
readiness.
|
||||
@ -6,11 +6,6 @@ save/discard/cancel prompt when needed. **File > Open Scene...** opens another t
|
||||
discarding the active document. Save and Save As affect the active tab, while project switching
|
||||
offers to save every modified tab.
|
||||
|
||||
Each tab also retains a canonical checkpoint of the last successfully loaded or saved authored
|
||||
projection. History commands remain active-tab runtime state, but undo and redo reconcile against
|
||||
that document checkpoint. Returning exactly to the checkpoint clears only that tab's dirty marker;
|
||||
redo, a divergent edit, failed save, or restored recovery remains dirty.
|
||||
|
||||
## Composition workflow
|
||||
|
||||
The **Composition** menu beside the tabs edits the active scene's persisted `SceneComposition`:
|
||||
|
||||
@ -16,13 +16,6 @@ loop or editor rendering.
|
||||
- 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.
|
||||
- Native window close, **File > Quit**, project switch, and programmatic editor exit share the
|
||||
guarded-shutdown coordinator. Dirty scene tabs present exact **Save All**, **Discard**, and
|
||||
**Cancel** actions. Save All remains pending through each untitled tab's Save As result and exits
|
||||
only after every dirty tab is clean; cancellation or failure preserves the live session.
|
||||
- A close request received while another native dialog owns the broker remains queued. The editor
|
||||
rechecks dirty state and retries acquisition after that workflow completes instead of dropping
|
||||
the request or opening a competing dialog.
|
||||
|
||||
## Covered Surfaces
|
||||
|
||||
@ -31,8 +24,6 @@ subscene selection, collaborative conflict copies, Project Browser folder select
|
||||
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).
|
||||
Guarded exit ownership and document savepoints are defined by
|
||||
[ADR 0042](../adr/0042-guarded-editor-shutdown-and-document-savepoints.md).
|
||||
|
||||
Live native-Wayland acceptance is recorded in the
|
||||
[native-dialog responsiveness evaluation](evaluations/native-dialog-responsiveness/).
|
||||
|
||||
@ -151,7 +151,7 @@ with implementation sequencing in
|
||||
| Milestone | Exit condition | Status |
|
||||
|-----------|----------------|--------|
|
||||
| M6 Reliability, recovery, and project workflow | Transactional save/recovery, stable sessions, project launcher, hardened hierarchy/prefabs, multi-scene composition | Implementation complete; all six scoped issues are closed after prefab #43 passed workspace, headless, packaged-runtime, and live editor acceptance |
|
||||
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#49 are complete after source, headless, and native acceptance. P0 #55 is complete at source implementation `4b33f32`, and follow-on P0 #57 scoped UI action safety is complete at `9e23ae7` after workspace, focused, independent-review, and native acceptance. No known P0 implementation blocker remains, but #50 is still the final candidate gate and production readiness is not established. Deterministic imported-asset fingerprints #56 are P1 follow-up work. Packaged acceptance is deferred until requested by the project owner. |
|
||||
| M7 Content production and shipping | Build/package profiles, content release gate, animation, audio, navigation, collaborative safety | Active; #44-#49 are complete after source, headless, and native acceptance. Final readiness gate #50 remains blocked by open P0 shutdown/savepoint work #55; deterministic imported-asset fingerprints #56 are P1 follow-up work. Packaged acceptance is deferred until requested by the project owner. |
|
||||
|
||||
Production readiness is not inferred from feature count. Gitea
|
||||
[`#50`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/50)
|
||||
|
||||
@ -13,14 +13,10 @@ or credentials.
|
||||
|
||||
## Restart behavior
|
||||
|
||||
- **Clean shutdown:** after the guarded exit path saves all dirty scene tabs or receives explicit
|
||||
Discard confirmation, the last authored scene and non-destructive UI state are restored.
|
||||
- **Clean shutdown:** the last authored scene and non-destructive UI state are restored.
|
||||
- **Abnormal shutdown:** the editor opens its normal safe scene and displays **Recover Editor
|
||||
Session**. **Resume Last Scene** opens only the previous authored scene. **Continue Safe** keeps
|
||||
the startup scene. Modal tools and dirty preview state are never restored.
|
||||
- Native window close, **File > Quit**, project switching, and programmatic editor exit use one
|
||||
non-blocking Save All / Discard / Cancel workflow. Cancel and failed saves keep the session open.
|
||||
Direct `AppExit`, process kill, and crashes are not recorded as clean shutdowns.
|
||||
- Dirty scene data remains governed by the independent scene recovery workflow in
|
||||
[ADR 0023](../adr/0023-transactional-scene-persistence-and-recovery.md).
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user