diff --git a/docs/editor/README.md b/docs/editor/README.md new file mode 100644 index 0000000000..01cee358ac --- /dev/null +++ b/docs/editor/README.md @@ -0,0 +1,54 @@ +# Editor Framework — Overview + +Stride's editor is built on a ViewModel layer that sits above Quantum and the asset system. Every mutation goes through a transaction stack that feeds undo/redo. A separate selection history stack, sharing the same low-level infrastructure, powers back/forward navigation. Both systems use `ITransactionStack` and `Operation` from the `Stride.Core.Transactions` namespace (assembly: `Stride.Core.Design`). + +## Shared Infrastructure + +```mermaid +flowchart TD + A["ITransactionStack + Operation
Stride.Core.Design · Stride.Core.Transactions
sources/core/Stride.Core.Design/Transactions/
Shared interface and base type — each consumer creates its own instance"] + B["IUndoRedoService
Stride.Core.Presentation
sources/presentation/Stride.Core.Presentation/Services/
Wraps stack with naming, dirtiable sync, and save-point tracking"] + C["SelectionService
Stride.Core.Assets.Editor
sources/editor/Stride.Core.Assets.Editor/Services/
Separate unbounded stack; records selection snapshots"] + + A -. "uses" .-> B + A -. "uses" .-> C +``` + +## Projects + +The editor codebase spans `sources/presentation/` (MVVM framework, Quantum-to-UI binding, shared controls) and `sources/editor/` (editor infrastructure and concrete asset editors). ViewModels are platform-agnostic; WPF coupling belongs in XAML files, code-behind, and WPF-specific service implementations. See [projects.md](projects.md) for the full project map and assembly reference. + +## When You Need These Systems + +> **Decision tree:** +> +> - Wrapping a property or collection mutation so it is undoable? +> → **`IUndoRedoService.CreateTransaction()` + `AnonymousDirtyingOperation`.** See [undo-redo.md](undo-redo.md). +> +> - Writing a reusable operation that merges consecutive edits on the same target? +> → **`DirtyingOperation` subclass + `IMergeableOperation`.** See [undo-redo.md](undo-redo.md). +> +> - Tracking which objects are "dirty" (unsaved) after changes? +> → **`IDirtiable` / `DirtiableManager`.** See [undo-redo.md](undo-redo.md). +> +> - Mutating a value through a Quantum node presenter (property grid edit)? +> → **No `PushOperation` needed** — `ContentValueChangeOperation` is pushed automatically by the Quantum infrastructure. See [undo-redo.md](undo-redo.md#how-quantum-feeds-the-stack-automatically). +> +> - Understanding how back/forward selection history works? +> → **`SelectionService`.** See [navigation.md](navigation.md). +> +> - Creating a dedicated editing surface for a new asset type? +> → **Write a custom editor.** See [custom-editor.md](custom-editor.md). +> +> - Understanding or modifying an existing editor? +> → **Existing editors catalogue.** See [editors.md](editors.md). + +## Spoke Files + +| File | Covers | +|---|---| +| [undo-redo.md](undo-redo.md) | `ITransactionStack`, `IUndoRedoService`, `DirtyingOperation`, `IMergeableOperation`, `IDirtiable`, dirty-flag synchronisation | +| [navigation.md](navigation.md) | `SelectionService`, selection history snapshots, back/forward navigation | +| [projects.md](projects.md) | Project inventory, WPF boundary rule, assembly map | +| [custom-editor.md](custom-editor.md) | Custom asset editor: base class, registration, lifecycle, services, MVVM patterns | +| [editors.md](editors.md) | Existing editors catalogue: SpriteSheet, Scene, Prefab, UIPage, UILibrary, GraphicsCompositor, Script, VisualScript | diff --git a/docs/editor/custom-editor.md b/docs/editor/custom-editor.md new file mode 100644 index 0000000000..7a1ab44a6c --- /dev/null +++ b/docs/editor/custom-editor.md @@ -0,0 +1,182 @@ +# Writing a Custom Asset Editor + +## Role + +A custom editor is a ViewModel + View pair registered against an `AssetViewModel` type. When the user double-clicks the asset in GameStudio, the framework instantiates the registered view, binds it to the registered ViewModel, and calls `InitializeEditor`. The ViewModel drives all logic; the View is pure WPF XAML bound to it. + +## Choosing a Base Class + +| Base class | Use when | What it adds | +|---|---|---| +| `AssetEditorViewModel` | Simple editor with no game viewport and no hierarchical parts (e.g. sprite sheet, graphics compositor, script) | Asset ownership, `Initialize`/`Destroy` lifecycle, `IUndoRedoService`, `SessionViewModel` | +| `GameEditorViewModel` | Editor that needs a live game instance for rendering (rarely subclassed directly — prefer the composite variant below) | `IEditorGameController` integration, game startup/shutdown, error recovery | +| `AssetCompositeHierarchyEditorViewModel` | Asset that contains a tree of selectable parts (scenes, prefabs, UI pages) | Selection tracking, copy/cut/paste/delete/duplicate for hierarchy parts, part ViewModel factory | + +## Registration + +Two attributes are required. Both are discovered automatically by `AssetsEditorPlugin` via reflection at startup — no manual registration needed. + +```csharp +// On the editor ViewModel class — maps AssetViewModel subtype → editor ViewModel type. +[AssetEditorViewModel<%%AssetName%%ViewModel>] +public sealed class %%AssetName%%EditorViewModel : AssetEditorViewModel +{ + public %%AssetName%%EditorViewModel([NotNull] %%AssetName%%ViewModel asset) + : base(asset) { } +} + +// On the view code-behind — maps editor ViewModel type → view type. +[AssetEditorView<%%AssetName%%EditorViewModel>] +public partial class %%AssetName%%EditorView : UserControl, IEditorView { ... } +``` + +Both classes must live in `Stride.Assets.Presentation` (or an assembly loaded as a plugin via `AssetsEditorPlugin`). + +## Lifecycle + +**1. Construction** — synchronous; `base(asset)` is the only required call; do not perform async work here. + +**2. `Initialize()`** + +```csharp +public override async Task Initialize() +{ + // Load resources, set up bindings, register selection scope. + // Return false to abort — the editor will not open and Destroy() will be called. + return true; +} +``` + +**3. Active editing** — user interacts; ViewModel handles commands; all mutations go through `UndoRedoService.CreateTransaction()` (see [undo-redo.md](undo-redo.md)). + +**4. `PreviewClose(bool? save)`** + +```csharp +public override bool PreviewClose(bool? save) +{ + if (save == null) + { + // Ask user — show a dialog via ServiceProvider.Get(). + // Return false to cancel close. + } + // save == true → force-save; save == false → discard. + return true; +} +``` + +**5. `Destroy()`** — inherited from the MVVM base infrastructure (`DispatcherViewModel`/`ViewModelBase`), not declared on `AssetEditorViewModel` itself; synchronous; unhook all events, stop game instance if any, release resources; must not throw; always call `base.Destroy()`. + +## The View + +Implement `IEditorView` in the code-behind. The XAML file contains only layout and data bindings — no business logic. + +```csharp +[AssetEditorView<%%AssetName%%EditorViewModel>] +public partial class %%AssetName%%EditorView : UserControl, IEditorView +{ + private readonly TaskCompletionSource editorInitializationTcs = new(); + + public object DataContext + { + get => base.DataContext; + set => base.DataContext = value; + } + + public Task EditorInitialization => editorInitializationTcs.Task; + + public async Task InitializeEditor(IAssetEditorViewModel editor) + { + if (!await editor.Initialize()) + { + editor.Destroy(); + return false; + } + // Wire up anything that requires the initialized ViewModel here + // (e.g. inject the game viewport: somePanel.Content = myEditor.Controller.EditorHost). + editorInitializationTcs.SetResult(); + return true; + } +} +``` + +## Services + +Access services via `ServiceProvider` (available on `AssetEditorViewModel`): + +| Service | Access | Purpose | +|---|---|---| +| `IUndoRedoService` | `ServiceProvider.Get()` | Wrap mutations in transactions — see [undo-redo.md](undo-redo.md) | +| `IDispatcherService` | `ServiceProvider.Get()` | Invoke code on the UI thread from a background thread | +| `IEditorDialogService` | `ServiceProvider.Get()` | Show dialogs, message boxes, and file pickers | +| `SelectionService` | `ServiceProvider.Get()` | Register selection scope for back/forward navigation — see [navigation.md](navigation.md) | +| `IAssetEditorsManager` | `ServiceProvider.TryGet()` | Open or close other asset editors programmatically | + +Use `TryGet()` for optional services; `Get()` throws if the service is not registered. + +`UndoRedoService` is also available as a shorthand property on `AssetEditorViewModel` (equivalent to `ServiceProvider.Get()`). + +## MVVM Patterns + +### Binding a property with automatic undo/redo + +`MemberGraphNodeBinding` wraps a Quantum `IMemberNode`; get/set route through the binding and undo/redo is handled automatically. Obtain the root `IObjectNode` via `Session.AssetNodeContainer` (see [quantum/asset-graph.md](../quantum/asset-graph.md)): + +```csharp +private readonly MemberGraphNodeBinding colorBinding; + +public %%AssetName%%EditorViewModel([NotNull] %%AssetName%%ViewModel asset) + : base(asset) +{ + // rootNode is an IObjectNode obtained via Session.AssetNodeContainer. + // See docs/quantum/asset-graph.md for how to retrieve it. + colorBinding = new MemberGraphNodeBinding( + rootNode[nameof(%%AssetName%%.Color)], // IMemberNode + nameof(%%AssetName%%EditorViewModel.Color), // ViewModel property name + OnPropertyChanging, + OnPropertyChanged, + UndoRedoService); +} + +public Color Color { get => colorBinding.Value; set => colorBinding.Value = value; } +``` + +### Manual transaction wrapping + +For mutations that bypass the node graph (direct collection changes, renaming, structural operations): + +```csharp +using (var transaction = UndoRedoService.CreateTransaction()) +{ + // perform mutations here + UndoRedoService.SetName(transaction, "Descriptive operation name"); +} +``` + +See [undo-redo.md](undo-redo.md#wrapping-a-mutation) for the full pattern including `AnonymousDirtyingOperation`. + +### Commands + +```csharp +public ICommandBase DoSomethingCommand { get; } + +public %%AssetName%%EditorViewModel([NotNull] %%AssetName%%ViewModel asset) + : base(asset) +{ + DoSomethingCommand = new AnonymousTaskCommand(ServiceProvider, DoSomethingAsync); +} + +private async Task DoSomethingAsync() +{ + using var transaction = UndoRedoService.CreateTransaction(); + // ... + UndoRedoService.SetName(transaction, "Do something"); +} +``` + +## Assembly Placement + +| File | Path | +|---|---| +| `%%AssetName%%EditorViewModel.cs` | `sources/editor/Stride.Assets.Presentation/AssetEditors/%%EditorName%%/ViewModels/` | +| `%%AssetName%%EditorView.xaml` | `sources/editor/Stride.Assets.Presentation/AssetEditors/%%EditorName%%/Views/` | +| `%%AssetName%%EditorView.xaml.cs` | `sources/editor/Stride.Assets.Presentation/AssetEditors/%%EditorName%%/Views/` | diff --git a/docs/editor/editors.md b/docs/editor/editors.md new file mode 100644 index 0000000000..c164268452 --- /dev/null +++ b/docs/editor/editors.md @@ -0,0 +1,137 @@ +# Existing Asset Editors + +## Overview + +All concrete asset editors live in `Stride.Assets.Presentation` under `sources/editor/Stride.Assets.Presentation/AssetEditors/`. Most editor folders contain a `ViewModels/` subdirectory and a `Views/` subdirectory; ScriptEditor and VisualScriptEditor are exceptions where files sit flat at the folder root. The table below is the entry point for locating any existing editor. + +## Editors + +| Editor | Asset type | Base class | Game viewport | Folder | +|---|---|---|---|---| +| `SpriteSheetEditorViewModel` | `SpriteSheetAsset` | `AssetEditorViewModel` | No | `SpriteEditor/` | +| `SceneEditorViewModel` | `SceneAsset` | `EntityHierarchyEditorViewModel` | Yes | `EntityHierarchyEditor/` | +| `PrefabEditorViewModel` | `PrefabAsset` | `EntityHierarchyEditorViewModel` | Yes | `EntityHierarchyEditor/` | +| `UIPageEditorViewModel` | `UIPageAsset` | `AssetCompositeHierarchyEditorViewModel` | Yes | `UIPageEditor/` | +| `UILibraryEditorViewModel` | `UILibraryAsset` | `AssetCompositeHierarchyEditorViewModel` | Yes | `UILibraryEditor/` | +| `GraphicsCompositorEditorViewModel` | `GraphicsCompositorAsset` | `AssetEditorViewModel` | No | `GraphicsCompositorEditor/` | +| `ScriptEditorViewModel` | Script assets | `AssetEditorViewModel` | No | `ScriptEditor/` | +| `VisualScriptEditorViewModel` | `VisualScriptAsset` | `AssetEditorViewModel` | No | `VisualScriptEditor/` | + +### SpriteSheetEditorViewModel + +**What it does:** Lets the user define sprites within a texture — regions, pivot points, borders, and animation frames. Renders a preview using its own lightweight `ViewportViewModel` rather than a full game instance. + +**Key types:** + +| Class | Role | +|---|---| +| `SpriteSheetEditorViewModel` | Editor ViewModel | +| `SpriteEditorView` | XAML view | +| `ViewportViewModel` | Lightweight render preview (no full game loop) | + +**Notable:** +- Uses `ViewportViewModel` for rendering instead of `IEditorGameController` — lighter than a full game editor. +- Implements `IAddChildViewModel` to support dragging textures onto the editor to add new sprites. + +### SceneEditorViewModel / PrefabEditorViewModel + +**What it does:** Full 3D scene and prefab editing with an entity hierarchy tree, transform gizmos, camera controls, and a live game viewport. Scene and prefab share the same base infrastructure with thin concrete subclasses. + +**Key types:** + +| Class | Role | +|---|---| +| `EntityHierarchyEditorViewModel` | Shared base ViewModel (`EntityHierarchyEditor/ViewModels/`) | +| `SceneEditorViewModel` | Thin subclass for scenes | +| `PrefabEditorViewModel` | Thin subclass for prefabs | +| `EntityViewModel` | Part ViewModel for each entity in the hierarchy | +| `EditorCameraViewModel` | Camera movement and controls (lives in `GameEditor/ViewModels/`, shared infrastructure) | +| `EntityGizmosViewModel` | Gizmo overlay (translate/rotate/scale handles) | +| `EntityHierarchyEditorView` | Abstract base view | +| `SceneEditorView` / `PrefabEditorView` | Concrete views | + +**Notable:** +- Most logic is in `EntityHierarchyEditorViewModel`; the concrete subclasses are thin. +- Scene and prefab differ primarily in which hierarchy root they load and whether archetype (prefab base) linking is active. +- The view code-behind injects the game host into the XAML panel: `SceneView.Content = hierarchyEditor.Controller.EditorHost`. + +### UIPageEditorViewModel / UILibraryEditorViewModel + +**What it does:** WYSIWYG editing of UI hierarchies — pages (full-screen layouts) and libraries (reusable component collections). Renders elements in a live game viewport with selection adorners, resize handles, and snap guidelines. + +**Key types:** + +| Class | Role | +|---|---| +| `UIEditorBaseViewModel` | Shared base ViewModel (`UIEditor/ViewModels/`) | +| `UIPageEditorViewModel` | Subclass for UI pages | +| `UILibraryEditorViewModel` | Subclass for UI libraries | +| `UIElementViewModel` | Part ViewModel for each UI element | +| `UIEditorView` | Abstract base view (`UIEditor/Views/`) | +| `UIPageEditorView` / `UILibraryEditorView` | Concrete views | + +**Notable:** +- The adorner overlay (guidelines, resize handles) is rendered as a WPF layer on top of the game viewport — one of the few places where WPF and game rendering are explicitly composited. +- `UIEditorBaseViewModel` handles element factories, zoom/pan state, and selection; subclasses add only asset-type-specific root handling. + +### GraphicsCompositorEditorViewModel + +**What it does:** Visual node-graph editor for the render pipeline. Nodes represent render features and render stages; edges represent data flow between them. + +**Key types:** + +| Class | Role | +|---|---| +| `GraphicsCompositorEditorViewModel` | Editor ViewModel (`GraphicsCompositorEditor/ViewModels/`) | +| `GraphicsCompositorEditorView` | XAML view | +| `SharedRendererFactoryViewModel` | Factory/list for shared renderer blocks | +| `RenderStageViewModel` | Node ViewModel for render stages | + +**Notable:** +- Uses `Stride.Core.Presentation.Graph` for the WPF node-graph canvas. +- Does **not** use `IEditorGameController` — no live game instance; the compositor is a pure data-structure editor. + +### ScriptEditorViewModel + +**What it does:** Opens a script asset in an embedded code editor. Provides compilation feedback and basic IDE integration within GameStudio. + +**Key types:** + +| Class | Role | +|---|---| +| `ScriptEditorViewModel` | Editor ViewModel (`ScriptEditor/`) | +| `ScriptEditorView` | XAML view | + +**Notable:** +- The lightest editor — mostly a shell around the embedded code editor control. +- Undo/redo is delegated to the code editor itself rather than `IUndoRedoService`; the standard transaction infrastructure does not apply here. + +### VisualScriptEditorViewModel + +**What it does:** Node-graph editor for visual scripting. Blocks represent operations; edges represent data and control flow between them. + +**Key types:** + +| Class | Role | +|---|---| +| `VisualScriptEditorViewModel` | Editor ViewModel (`VisualScriptEditor/`) | +| `VisualScriptMethodEditorViewModel` | Per-method graph editing | +| `VisualScriptBlockViewModel` | Node ViewModel for each block | +| `VisualScriptLinkViewModel` | Edge ViewModel for each connection | + +**Notable:** +- Similar to `GraphicsCompositorEditorViewModel` in structure: a pure data-structure editor with no game instance. +- ViewModel files sit at the folder root (no `ViewModels/` subdir); views are in `Views/` as usual. + +## Shared Game Editor Infrastructure + +All game-viewport editors (Scene, Prefab, UIPage, UILibrary) inherit from `GameEditorViewModel` and run a game instance via `IEditorGameController`. The controller manages the game loop on a background thread; results are marshalled back to the ViewModel via `IDispatcherService`. + +**Key implication for contributors:** property changes in these editors can originate from either the UI thread (user interaction) or the game thread (simulation update). Code that modifies ViewModel state from the game thread must dispatch to the UI thread via `Asset.Dispatcher` (`AssetEditorViewModel.Asset` inherits `Dispatcher` from `DispatcherViewModel`): + +```csharp +Asset.Dispatcher.InvokeAsync(() => +{ + // safe to update ViewModel properties here +}); +``` diff --git a/docs/editor/navigation.md b/docs/editor/navigation.md new file mode 100644 index 0000000000..15e9331839 --- /dev/null +++ b/docs/editor/navigation.md @@ -0,0 +1,52 @@ +# Selection History (Back/Forward Navigation) + +## Role + +`SelectionService` records each selection change as a `SelectionOperation` on its own `ITransactionStack`, completely separate from undo/redo. Rolling back that stack restores the previous selection; rolling forward restores the next. The stack has unlimited capacity and is never surfaced in the undo/redo history. From the user's perspective, this is the back/forward navigation in GameStudio. + +## Key Types + +| Type | Assembly | Purpose | +|---|---|---| +| `SelectionService` | `Stride.Core.Assets.Editor`
`sources/editor/Stride.Core.Assets.Editor/Services/` | Owns the navigation stack; exposes `NavigateBackward` / `NextSelection`; registers observable collections to track | +| `SelectionState` | same | Snapshot of all registered collection states at a point in time; `HasValidSelection()` checks whether the referenced objects still exist | +| `SelectionScope` | same | A group of `INotifyCollectionChanged` collections tracked together as a unit; always obtained from `RegisterSelectionScope()`, never constructed directly | +| `SelectionOperation` | `sources/editor/Stride.Core.Assets.Editor/Services/SelectionOperation.cs` | `Operation` subclass pairing a previous and next `SelectionState`; `Rollback` restores previous, `Rollforward` restores next | + +## How It Works + +`RegisterSelectionScope()` subscribes the service to one or more observable collections. Each time any of those collections changes, the service captures a `SelectionState` snapshot (recording the current contents of all registered collections) and pushes a `SelectionOperation` wrapping the previous and new states. + +`NavigateBackward()` keeps rolling back while the resulting state either still equals the state it started from (the rollback had no observable effect on the selection) or has no valid selection (`HasValidSelection()` returns `false`). This means both no-op entries and entries whose objects have been deleted are skipped transparently. + +`NextSelection()` does the same in the rollforward direction. + +## `SelectionService` Members + +| Member | Type | Purpose | +|---|---|---| +| `CanGoBack` | `bool` | Whether the navigation stack can roll back | +| `CanGoForward` | `bool` | Whether the navigation stack can roll forward | +| `NavigateBackward()` | `void` | Roll back to the previous valid selection state | +| `NextSelection()` | `void` | Roll forward to the next valid selection state | +| `RegisterSelectionScope(idToObject, objectToId, collections)` | `SelectionScope` | Hook the service into observable collections; `idToObject`/`objectToId` maps allow states to be serialised using `AbsoluteId` without holding strong object references | + +Access via `ServiceProvider.Get()`. This is the concrete class — there is no `ISelectionService` interface. + +Note that `NextSelection()` does not follow the `Navigate` prefix used by its counterpart `NavigateBackward()` — this asymmetry is in the source API itself, not a documentation error. + +`RegisterSelectionScope` is typically called by the editor framework when setting up an editor panel or session, not by individual contributor code. The `idToObject` and `objectToId` parameters are `Func` and `Func` — they map between runtime object references and stable `AbsoluteId` values (defined in `Stride.Core.Assets`) so that selection states survive object reloads. + +## Relationship to Undo/Redo + +The two systems share `ITransactionStack` and `Operation` from `Stride.Core.Transactions` but use **completely separate instances**: + +- Undoing a mutation does not move the navigation cursor. +- Navigating back does not undo any mutation. +- The navigation stack has no `DirtyingOperation` or `IDirtiable` involvement — selection is not a dirtying action. + +`SelectionService` uses a raw `ITransactionStack` (not `IUndoRedoService`) with capacity `int.MaxValue`. It does not use `DirtyingOperation` or `AnonymousDirtyingOperation`. + +## Assembly Placement + +`SelectionService`, `SelectionState`, `SelectionScope`, and `SelectionOperation` all live in `Stride.Core.Assets.Editor` at `sources/editor/Stride.Core.Assets.Editor/Services/`. diff --git a/docs/editor/projects.md b/docs/editor/projects.md new file mode 100644 index 0000000000..7b53a7d8cf --- /dev/null +++ b/docs/editor/projects.md @@ -0,0 +1,35 @@ +# Editor Projects + +## Role + +The editor codebase is split across `sources/presentation/` (MVVM framework, shared controls, Quantum-to-UI binding) and `sources/editor/` (editor infrastructure and concrete asset editors). WPF is the only supported view layer, but ViewModels are written to be platform-agnostic. This file maps each project to its responsibility and WPF coupling status. + +## The WPF Boundary + +**Rule:** Anything in a ViewModel subclass (`AssetEditorViewModel`, `INodePresenterUpdater`, etc.) must not reference WPF types (`DependencyObject`, `FrameworkElement`, `Dispatcher`, etc.). WPF coupling belongs in: + +- XAML files and code-behind implementing `IEditorView` +- WPF-specific service implementations, controls, and behaviors in `Stride.Core.Presentation.Wpf` + +Exceptions exist in the codebase for historical reasons. Do not add new ones. + +## Project Map + +Assembly names match project names throughout; the "same" shorthand in the Assembly column indicates this. + +| Project | Assembly | WPF | Responsibility | +|---|---|---|---| +| `Stride.Core.Presentation` | same | No | MVVM base classes (`ViewModelBase`, `DispatcherViewModel`), service interfaces (`IDispatcherService`), commands, dirtiables | +| `Stride.Core.Presentation.Wpf` | same | Yes | WPF controls, converters, behaviors, `WpfDispatcherService` | +| `Stride.Core.Presentation.Quantum` | same | No | Quantum-to-ViewModel binding — `INodePresenter`, node presenter updater infrastructure; platform-agnostic layer consumed by the WPF property grid | +| `Stride.Core.Presentation.Graph` | same | Yes | Node-graph visualization controls (used by GraphicsCompositor editor) | +| `Stride.Core.Presentation.Dialogs` | same | Yes | Dialog services and file picker implementations | +| `Stride.Core.Assets.Editor` | same | Yes* | Base editor infrastructure: `AssetEditorViewModel`, `IEditorView`, `AssetsEditorPlugin`, `SelectionService`, `IUndoRedoService`, registration attributes; *project targets WPF but ViewModel base classes are platform-agnostic | +| `Stride.Assets.Presentation` | same | Yes | All concrete asset editors (ViewModels + Views), `StrideDefaultAssetsPlugin`, node presenter updaters | +| `Stride.Editor` | same | Yes | Core editor wiring and game-editor infrastructure | +| `Stride.GameStudio` | same | Yes | Shell, `AssetEditorsManager`, `PluginService`, main window | + +## Where to Put New Code + +- **New ViewModel code** → `Stride.Assets.Presentation`, under `sources/editor/Stride.Assets.Presentation/AssetEditors/%%EditorName%%/ViewModels/` +- **New View / XAML code** → same project, `AssetEditors/%%EditorName%%/Views/` diff --git a/docs/editor/undo-redo.md b/docs/editor/undo-redo.md new file mode 100644 index 0000000000..82ba1faf17 --- /dev/null +++ b/docs/editor/undo-redo.md @@ -0,0 +1,139 @@ +# Undo/Redo + +## Role + +Every mutation in the editor is wrapped in a transaction. When the transaction completes, it lands on the undo/redo stack as a single undoable unit. Rolling back replays the operations it contains in reverse. The dirtiable system tracks which assets have unsaved changes relative to the last save snapshot, updating automatically as the stack changes. + +## Architecture + +Two layers sit between a mutation and the undo/redo stack: + +| Layer | Assembly | Key Types | Purpose | +|---|---|---|---| +| Transaction core | `Stride.Core.Design`
`sources/core/Stride.Core.Design/Transactions/` | `ITransactionStack`, `Transaction`, `Operation`, `IMergeableOperation` | Generic bounded stack and reversible operation primitives | +| Presentation service | `Stride.Core.Presentation`
`sources/presentation/Stride.Core.Presentation/` | `IUndoRedoService`, `DirtyingOperation`, `AnonymousDirtyingOperation`, `IDirtiable`, `DirtiableManager` | Service wrapper with human-readable names, dirty-flag integration, and save snapshots | + +Editor-specific operations (`ContentValueChangeOperation`) live in `Stride.Core.Assets.Editor` (`sources/editor/Stride.Core.Assets.Editor/Quantum/`) and extend the base model with Quantum node references. + +## `IUndoRedoService` + +Access via `ServiceProvider.Get()`. Both `AssetEditorViewModel` and `AssetViewModel` expose it via their inherited `ServiceProvider`. + +| Member | Type | Purpose | +|---|---|---| +| `CreateTransaction()` | `ITransaction` | Begin a new transaction; returns a `DummyTransaction` when `UndoRedoInProgress == true` — the dummy is safe to dispose, but calling `PushOperation` during this window will throw; guard with `if (!UndoRedoService.UndoRedoInProgress)` if the mutation can be triggered during rollback | +| `PushOperation(Operation)` | `void` | Add an operation to the current transaction | +| `SetName(ITransaction, string)` | `void` | Attach a human-readable name shown in the undo/redo history panel | +| `Undo()` / `Redo()` | `void` | Roll back / roll forward the top transaction | +| `CanUndo` / `CanRedo` | `bool` | Whether rollback/rollforward is available | +| `UndoRedoInProgress` | `bool` | `true` while a rollback or rollforward is executing; `CreateTransaction()` is a no-op during this window to prevent re-entrant operations | +| `NotifySave()` | `void` | Mark the current stack position as the clean state; `IsDirty` becomes `false` for all tracked objects | +| `Done` / `Undone` / `Redone` | events | Raised after each transaction completes, is rolled back, or is rolled forward | +| `TransactionDiscarded` | event | Raised when the stack is full and the oldest transaction is dropped | + +## Wrapping a Mutation + +The standard pattern — use this whenever you mutate state that should be undoable: + +```csharp +using (var transaction = UndoRedoService.CreateTransaction()) +{ + var previousValue = target.SomeProperty; + target.SomeProperty = newValue; + + UndoRedoService.PushOperation(new AnonymousDirtyingOperation( + dirtiables: this.Yield(), // 'this' is the AssetViewModel; see IDirtiable section below + undo: () => { target.SomeProperty = previousValue; }, + redo: () => { target.SomeProperty = newValue; } + )); + + // SetName is conventionally placed after mutations. + UndoRedoService.SetName(transaction, "Set SomeProperty"); +} +``` + +`new[] { this }` captures the current instance as an `IEnumerable`. The codebase also uses `this.Yield()` (a Stride extension from the `Stride.Core.Extensions` namespace, in assembly `Stride.Core.Design`), which is equivalent — use whichever is already imported in the file you are editing. Pass the `AssetViewModel` (or any `IDirtiable`) so `DirtiableManager` knows which asset to mark dirty. + +`ITransaction` implements `IDisposable`. The `using` block calls `Dispose()`, completing the transaction and pushing it onto the stack. Nesting `CreateTransaction()` calls inside an outer `using` block creates a sub-transaction — it becomes a single `Operation` of the outer transaction when it completes. + +## Writing a Reusable Operation + +For operations used in multiple places or that benefit from consecutive-edit merging, subclass `DirtyingOperation`: + +```csharp +// sources/editor/Stride.Assets.Presentation/YourFeature/%%OperationName%%Operation.cs +using Stride.Core.Presentation.Dirtiables; +using Stride.Core.Transactions; + +namespace Stride.Assets.Presentation.YourFeature; + +internal sealed class %%OperationName%%Operation : DirtyingOperation, IMergeableOperation +{ + private readonly SomeTargetType target; + private readonly ValueType previousValue; + private ValueType newValue; + + public %%OperationName%%Operation( + SomeTargetType target, + ValueType previousValue, + ValueType newValue, + IEnumerable dirtiables) + : base(dirtiables) + { + this.target = target; + this.previousValue = previousValue; + this.newValue = newValue; + } + + // DirtyingOperation seals Rollback()/Rollforward() and routes them to these two methods. + protected override void Undo() => target.Value = previousValue; + protected override void Redo() => target.Value = newValue; + + // IMergeableOperation — optional; merge consecutive edits on the same target to reduce + // stack bloat. When `Transaction.Complete()` is called (i.e. when the `using` block exits), + // the transaction itself calls `CanMerge` on each consecutive pair of operations it holds. + // If `CanMerge` returns `true`, `Merge` is called on the earlier operation, passing the + // later one as the argument; the later operation is then removed. + public bool CanMerge(IMergeableOperation otherOperation) + => otherOperation is %%OperationName%%Operation op && op.target == target; + + public void Merge(Operation otherOperation) + { + // Absorb the newer operation's final value so a single undo jumps directly + // from the newest value back to the original previousValue. + newValue = ((%%OperationName%%Operation)otherOperation).newValue; + } +} +``` + +`DirtyingOperation` declares `protected abstract void Undo()` and `protected abstract void Redo()` — implement only those two. The `Dirtiables` constructor parameter is forwarded to the base class and used by `DirtiableManager`. + +`IMergeableOperation` is optional. For the canonical implementation, see `ContentValueChangeOperation` at `sources/editor/Stride.Core.Assets.Editor/Quantum/ContentValueChangeOperation.cs` — it merges consecutive value edits on the same Quantum node index. + +## `IDirtiable` and Dirty Flags + +`IDirtiable` marks an object as "modified since last save". `AssetViewModel` already implements it — contributors do not need to implement it on new classes. + +`DirtiableManager` listens to the `ITransactionStack` events and automatically calls `UpdateDirtiness(bool)` on all `IDirtiable` objects referenced by operations on the stack: + +- After a new transaction is pushed: its dirtiables become dirty. +- After `Undo()` / `Redo()`: dirty state is recalculated against the current stack position. +- After `NotifySave()`: the current stack position is snapshotted; all tracked dirtiables become clean. + +Pass `new[] { this }` (or `this.Yield()`, or the asset's `.Dirtiables` property) when constructing any operation — this is the link between an operation and the objects it marks dirty. + +## How Quantum Feeds the Stack Automatically + +When a property value changes through a Quantum node presenter (e.g. the user edits a field in the property grid), `ContentValueChangeOperation` is created and pushed onto the stack automatically by the Quantum graph infrastructure. Contributors using `INodePresenterUpdater` or mutating values through `INodePresenter.UpdateValue()` get undo/redo for free — no manual `PushOperation` call is needed. + +Manual `PushOperation` is only required for mutations that bypass the node graph: direct collection manipulation, renaming an asset, or structural changes not expressed as node value updates. + +## Assembly Placement + +| Type | Assembly | Path | +|---|---|---| +| `ITransactionStack`, `Transaction`, `Operation`, `IMergeableOperation` | `Stride.Core.Design` | `sources/core/Stride.Core.Design/Transactions/` | +| `IUndoRedoService`, `DirtyingOperation`, `AnonymousDirtyingOperation` | `Stride.Core.Presentation` | `sources/presentation/Stride.Core.Presentation/Services/` and `Dirtiables/` | +| `IDirtiable`, `DirtiableManager` | `Stride.Core.Presentation` | `sources/presentation/Stride.Core.Presentation/Dirtiables/` | +| `ContentValueChangeOperation` | `Stride.Core.Assets.Editor` | `sources/editor/Stride.Core.Assets.Editor/Quantum/` | +| Your custom operation | `Stride.Assets.Presentation` | `sources/editor/Stride.Assets.Presentation/YourFeature/` |