feat(graphics): refactor pipeline keying and allocators
Major refactor of graphics pipeline keying, shader cache, and resource allocation. Replaced most Allocator usage with AllocationHandle, modernized logger usage, and unified pipeline state keys. Updated MeshUtility to use AllocationHandle.FreeList. Added new shader pipeline architecture docs and improved error handling throughout. BREAKING CHANGE: Pipeline keying and resource allocation APIs have changed.
This commit is contained in:
457
docs/notes/shader_pipeline_architecture.md
Normal file
457
docs/notes/shader_pipeline_architecture.md
Normal file
@@ -0,0 +1,457 @@
|
||||
# Shader Pipeline Architecture — Proposed Design
|
||||
|
||||
> Presented as a design walkthrough. Take what's useful, ignore what doesn't fit your vision.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Topology
|
||||
|
||||
The first decision: **where does each responsibility live?**
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph EditorProcess["Ghost.Editor Process"]
|
||||
FW["FileWatcher<br/>(monitors .ghost DSL files)"]
|
||||
AR["AssetRegistry<br/>(GUID ↔ file path mapping)"]
|
||||
EP["Editor UI<br/>(status bar, material inspector)"]
|
||||
end
|
||||
|
||||
subgraph CompilerProcess["GhostShaderServer Process"]
|
||||
DSL["DSL Compiler<br/>(Ghost DSL → HLSL)"]
|
||||
DXC["DXC Compiler<br/>(HLSL → DXIL bytecode)"]
|
||||
MW["Manifest Writer<br/>(updates variant → hash mapping)"]
|
||||
end
|
||||
|
||||
subgraph RuntimeGraphics["Ghost.Graphics (Runtime)"]
|
||||
SL["ShaderLibrary<br/>(reads bytecode from cache)"]
|
||||
PL["PipelineLibrary<br/>(PSO creation + double-buffer)"]
|
||||
RGC["RenderGraphContext<br/>(binds PSO per draw call)"]
|
||||
BR["IShaderCompilationBridge<br/>(interface, 2 methods)"]
|
||||
end
|
||||
|
||||
subgraph SharedDisk["Shared Disk (ShaderCache/)"]
|
||||
MF["ShaderManifest.bin<br/>(GUID+variant → content hash)"]
|
||||
BC["Bytecode Files<br/>(content-addressed .bin blobs)"]
|
||||
end
|
||||
|
||||
FW -- "file changed event" --> AR
|
||||
AR -- "GUID + file path<br/>(named pipe)" --> CompilerProcess
|
||||
DSL --> DXC
|
||||
DXC -- "bytecode bytes" --> MW
|
||||
MW -- "write blob" --> BC
|
||||
MW -- "update entry" --> MF
|
||||
|
||||
SL -- "read blob" --> BC
|
||||
SL -- "read mapping" --> MF
|
||||
BR -- "status query<br/>(named pipe)" --> CompilerProcess
|
||||
|
||||
EP -- "poll status" --> BR
|
||||
|
||||
style CompilerProcess fill:#1a1a2e,stroke:#e94560,color:#eee
|
||||
style EditorProcess fill:#1a1a2e,stroke:#0f3460,color:#eee
|
||||
style RuntimeGraphics fill:#1a1a2e,stroke:#16213e,color:#eee
|
||||
style SharedDisk fill:#0f3460,stroke:#533483,color:#eee
|
||||
```
|
||||
|
||||
### Why a Separate Process?
|
||||
|
||||
| Concern | In-process compiler | Separate process |
|
||||
|---------|-------------------|------------------|
|
||||
| DXC crash | Editor dies | Server restarts, editor lives |
|
||||
| DXC memory leak | Editor bloats over time | Kill & restart server periodically |
|
||||
| Parallelism | Threads compete with editor UI | Fully independent CPU budget |
|
||||
| Build pipeline reuse | Need separate build-time path | Same server binary, different mode |
|
||||
| Complexity | Lower (one process) | Higher (IPC needed) |
|
||||
|
||||
> [!TIP]
|
||||
> If the separate process feels like overkill for your current stage, **start with in-process behind the `IShaderCompilationBridge` interface**, then swap the implementation to out-of-process later. The interface is the same either way.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Model — The Manifest
|
||||
|
||||
This is the most important data structure in the entire system. It decouples **identity** from **content**.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph ShaderAsset["Shader Asset (on disk)"]
|
||||
GUID["Asset GUID<br/><i>e.g. 7f3a-...-c82b</i><br/>stable forever"]
|
||||
SRC["Source Code<br/><i>.ghost DSL file</i><br/>changes on edit"]
|
||||
end
|
||||
|
||||
subgraph Manifest["ShaderManifest"]
|
||||
E1["Entry:<br/>GUID=7f3a | Pass=0 | Variant=0x00<br/>→ ContentHash=0xABCD"]
|
||||
E2["Entry:<br/>GUID=7f3a | Pass=0 | Variant=0x01<br/>→ ContentHash=0x1234"]
|
||||
E3["Entry:<br/>GUID=7f3a | Pass=1 | Variant=0x00<br/>→ ContentHash=0x5678"]
|
||||
end
|
||||
|
||||
subgraph Cache["ShaderCache/ (content addressed)"]
|
||||
B1["AB/shader_cache_ABCD...bin"]
|
||||
B2["12/shader_cache_1234...bin"]
|
||||
B3["56/shader_cache_5678...bin"]
|
||||
end
|
||||
|
||||
GUID --> E1
|
||||
GUID --> E2
|
||||
GUID --> E3
|
||||
E1 --> B1
|
||||
E2 --> B2
|
||||
E3 --> B3
|
||||
|
||||
style ShaderAsset fill:#16213e,stroke:#0f3460,color:#eee
|
||||
style Manifest fill:#1a1a2e,stroke:#e94560,color:#eee
|
||||
style Cache fill:#0f3460,stroke:#533483,color:#eee
|
||||
```
|
||||
|
||||
### Manifest Entry Structure
|
||||
|
||||
```
|
||||
ManifestKey = Hash(AssetGUID + PassIndex + VariantKeywordMask)
|
||||
ManifestValue = ContentHash (= Hash of compiled bytecode)
|
||||
```
|
||||
|
||||
- **ManifestKey** is *structurally* derived — same shader, same pass, same keywords = same key, regardless of source changes.
|
||||
- **ContentHash** is *content-derived* — changes every time the source code changes.
|
||||
- When source changes: the ManifestKey stays the same, but the ContentHash it points to gets updated.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The `Shader` struct in runtime only needs to know the **AssetGUID**. It never stores or cares about content hashes. The `ShaderLibrary` uses the manifest to translate `(GUID, Pass, Variant) → ContentHash → File`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Compilation Flow — What Happens When You Save a Shader
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant FileSystem
|
||||
participant Editor as Ghost.Editor
|
||||
participant Server as ShaderServer
|
||||
participant Cache as ShaderCache/
|
||||
|
||||
User->>FileSystem: Save "water.ghost"
|
||||
FileSystem-->>Editor: FileWatcher event
|
||||
|
||||
Editor->>Editor: Lookup GUID for "water.ghost"<br/>via AssetRegistry
|
||||
Editor->>Server: CompileRequest {<br/> guid: 7f3a-...,<br/> filePath: "water.ghost",<br/> defines: [...],<br/> platform: D3D12<br/>}
|
||||
|
||||
Note over Server: Mark status = Compiling<br/>for this GUID
|
||||
|
||||
Server->>Server: Read .ghost DSL file
|
||||
Server->>Server: DSL Compiler: DSL → HLSL
|
||||
|
||||
alt DSL has syntax errors
|
||||
Server->>Server: Mark status = Error
|
||||
Server-->>Editor: CompileResult {<br/> status: Error,<br/> errors: [...]<br/>}
|
||||
Editor->>Editor: Show errors in<br/>console/inspector
|
||||
else DSL is valid
|
||||
Server->>Server: For each (pass, variant):<br/>DXC Compile HLSL → DXIL
|
||||
|
||||
alt Any DXC error
|
||||
Server->>Server: Mark status = Error
|
||||
Server-->>Editor: CompileResult {<br/> status: Error,<br/> errors: [...]<br/>}
|
||||
else All variants compiled
|
||||
Server->>Cache: Write bytecode blobs<br/>(content-addressed)
|
||||
Server->>Cache: Update manifest entries:<br/>(GUID+pass+variant) → new hash
|
||||
Server->>Server: Mark status = Ready
|
||||
Server-->>Editor: CompileResult {<br/> status: Ready,<br/> variantCount: N<br/>}
|
||||
Editor->>Editor: Show ✓ in status bar
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Key Design Decision: Compile All Variants Upfront?
|
||||
|
||||
**No.** Only compile variants that are *currently referenced* by materials in the scene. The editor knows which materials reference which shader (via AssetRegistry), and which keyword combinations those materials use. Ship only what's needed.
|
||||
|
||||
For the edit-time hot-reload, you really only need the specific variants the viewport is currently rendering. The full permutation set is a build-time concern.
|
||||
|
||||
---
|
||||
|
||||
## 4. Runtime PSO Resolution — The Frame-by-Frame Flow
|
||||
|
||||
This is where most of the complexity lives. Here's what `SetActiveMaterial` does every frame:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["SetActiveMaterial(material)"] --> B["Compute ManifestKey<br/>= f(shader.GUID, passIndex, variantMask)"]
|
||||
B --> C{"PipelineLibrary<br/>has PSO for<br/>ManifestKey?"}
|
||||
|
||||
C -- "Yes (cache hit)" --> D["Bind existing PSO<br/>to command buffer"]
|
||||
D --> Z["Done ✓"]
|
||||
|
||||
C -- "No (cache miss)" --> E{"ShaderLibrary<br/>has bytecode for<br/>ManifestKey?"}
|
||||
|
||||
E -- "Yes (manifest hit)" --> F["Read bytecode<br/>from cache file"]
|
||||
F --> G["Create PSO from bytecode"]
|
||||
G --> H["Store in PipelineLibrary"]
|
||||
H --> D
|
||||
|
||||
E -- "No (manifest miss)" --> I{"Is this Editor<br/>or Runtime?"}
|
||||
|
||||
I -- "Runtime<br/>(shipped game)" --> J["Bind Fallback<br/>ERROR PSO ⚠️"]
|
||||
J --> K["Log error:<br/>missing shader"]
|
||||
K --> Z
|
||||
|
||||
I -- "Editor" --> L{"Query Bridge:<br/>IsCompiling?"}
|
||||
|
||||
L -- "Status = Compiling" --> M["Bind OLD PSO<br/>(keep previous frame's shader)"]
|
||||
M --> Z
|
||||
|
||||
L -- "Status = Error" --> N["Bind ERROR PSO<br/>(magenta)"]
|
||||
N --> Z
|
||||
|
||||
L -- "Status = Ready" --> O["The manifest was just updated.<br/>Re-read manifest entry."]
|
||||
O --> F
|
||||
|
||||
L -- "Status = NotAvailable" --> J
|
||||
|
||||
style A fill:#533483,stroke:#e94560,color:#eee
|
||||
style D fill:#16213e,stroke:#0f3460,color:#eee
|
||||
style J fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||
style M fill:#0f3460,stroke:#533483,color:#eee
|
||||
style N fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||
style Z fill:#16213e,stroke:#16213e,color:#eee
|
||||
```
|
||||
|
||||
### The "Keep Old PSO" Strategy — How It Works Mechanically
|
||||
|
||||
This is the part that makes the UX feel seamless. The trick:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph PipelineLibrary
|
||||
direction TB
|
||||
K["ManifestKey 0xAABB"]
|
||||
K --> CURRENT["current: PSO_v2 ✓<br/>(what we render with)"]
|
||||
K --> PENDING["pending: null<br/>(set during recompilation)"]
|
||||
end
|
||||
|
||||
style CURRENT fill:#16213e,stroke:#0f3460,color:#eee
|
||||
style PENDING fill:#1a1a2e,stroke:#e94560,color:#eee
|
||||
```
|
||||
|
||||
When shader source changes and recompilation starts:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph PipelineLibrary_During["During Recompilation"]
|
||||
direction TB
|
||||
K2["ManifestKey 0xAABB"]
|
||||
K2 --> CURRENT2["current: PSO_v2 ✓<br/>(still rendering with this)"]
|
||||
K2 --> PENDING2["pending: COMPILING<br/>(server is working...)"]
|
||||
end
|
||||
|
||||
style CURRENT2 fill:#16213e,stroke:#0f3460,color:#eee
|
||||
style PENDING2 fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||
```
|
||||
|
||||
When recompilation finishes successfully:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph PipelineLibrary_After["After Swap"]
|
||||
direction TB
|
||||
K3["ManifestKey 0xAABB"]
|
||||
K3 --> CURRENT3["current: PSO_v3 ✓<br/>(new shader, rendering now)"]
|
||||
K3 --> PENDING3["pending: null<br/>(swap complete)"]
|
||||
end
|
||||
|
||||
style CURRENT3 fill:#16213e,stroke:#0f3460,color:#eee
|
||||
style PENDING3 fill:#1a1a2e,stroke:#533483,color:#eee
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The old `PSO_v2` is **not immediately destroyed**. It stays alive until the GPU is done with any in-flight frames referencing it (tracked by fence value). This prevents use-after-free on the GPU timeline.
|
||||
|
||||
---
|
||||
|
||||
## 5. Hot-Reload Sequence — The Complete Picture
|
||||
|
||||
Everything combined into one timeline:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Editor
|
||||
participant Server as ShaderServer
|
||||
participant Cache as Disk Cache
|
||||
participant Runtime as RenderGraphContext
|
||||
participant GPU
|
||||
|
||||
Note over Runtime,GPU: Frame N: Rendering with PSO_v2
|
||||
|
||||
User->>Editor: Edit & save "water.ghost"
|
||||
Editor->>Server: CompileRequest(guid=7f3a)
|
||||
Server->>Server: status[7f3a] = Compiling
|
||||
|
||||
Note over Runtime,GPU: Frame N+1
|
||||
Runtime->>Runtime: SetActiveMaterial()
|
||||
Runtime->>Runtime: ManifestKey lookup → old hash still there
|
||||
Runtime->>Runtime: PipelineLibrary has PSO → use it
|
||||
Note over Runtime: Still rendering with PSO_v2<br/>(user sees no flicker)
|
||||
|
||||
Note over Server: Background: DSL→HLSL→DXC...
|
||||
|
||||
Note over Runtime,GPU: Frame N+2, N+3, ...
|
||||
Runtime->>Runtime: Same as N+1, no visible change
|
||||
|
||||
Server->>Cache: Write new bytecode files
|
||||
Server->>Cache: Update manifest:<br/>key(7f3a,0,0) → new_hash
|
||||
Server->>Server: status[7f3a] = Ready
|
||||
|
||||
Note over Runtime,GPU: Frame N+K (compilation done)
|
||||
Runtime->>Runtime: SetActiveMaterial()
|
||||
Runtime->>Runtime: Manifest read → NEW content hash
|
||||
Runtime->>Runtime: PipelineLibrary miss for new hash
|
||||
Runtime->>Cache: Read new bytecode
|
||||
Runtime->>GPU: Create PSO_v3
|
||||
Runtime->>Runtime: PipelineLibrary: current=PSO_v3
|
||||
Runtime->>Runtime: Bind PSO_v3
|
||||
|
||||
Note over Runtime,GPU: Frame N+K+1: Rendering with PSO_v3 ✓
|
||||
|
||||
Runtime->>Runtime: Defer release PSO_v2<br/>(after GPU fence)
|
||||
```
|
||||
|
||||
### What the User Sees
|
||||
|
||||
| Frame | Viewport | Status Bar |
|
||||
|-------|----------|------------|
|
||||
| N | Water renders normally | — |
|
||||
| N+1 | Water renders normally (old shader) | 🔄 Compiling water.ghost... |
|
||||
| N+2 | Water renders normally (old shader) | 🔄 Compiling water.ghost... |
|
||||
| N+K | Water renders with new shader | ✅ water.ghost compiled (2 variants) |
|
||||
|
||||
**Zero flicker. Zero blocking. Zero pink frames.**
|
||||
|
||||
---
|
||||
|
||||
## 6. How the Manifest Key Replaces Your Current Hash Problem
|
||||
|
||||
Here's a before/after of your `Shader` struct:
|
||||
|
||||
### Current Design (problematic)
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Current["Current: Hash = f(source code)"]
|
||||
S1["Shader struct"] --> P1["Pass[0].Key = 0xABCD<br/><i>derived from source hash</i>"]
|
||||
P1 --> V1["ShaderVariantKey = f(0xABCD, keywords)"]
|
||||
V1 --> PK1["PipelineKey = f(variant, rtv, dsv)"]
|
||||
PK1 --> PSO1["PSO lookup in PipelineLibrary"]
|
||||
|
||||
EDIT["User edits source"] -.-> STALE["Pass[0].Key is now STALE ❌<br/>Still 0xABCD, but source changed"]
|
||||
STALE -.-> WRONG["Looks up OLD bytecode<br/>or worse, the old PSO"]
|
||||
end
|
||||
|
||||
style STALE fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||
style WRONG fill:#e94560,stroke:#1a1a2e,color:#eee
|
||||
```
|
||||
|
||||
### Proposed Design (stable)
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph Proposed["Proposed: Key = f(GUID, pass index)"]
|
||||
S2["Shader struct<br/>assetGUID = 7f3a-..."] --> P2["Pass[0]: index=0<br/><i>no source hash stored</i>"]
|
||||
P2 --> MK["ManifestKey = f(7f3a, 0, keywords)"]
|
||||
MK --> MANIFEST["Manifest Lookup<br/>→ ContentHash = 0x9999"]
|
||||
MANIFEST --> SL2["ShaderLibrary<br/>→ read 99/shader_cache_9999.bin"]
|
||||
SL2 --> PSO2["Create or get PSO"]
|
||||
|
||||
EDIT2["User edits source"] -.-> RECOMP["Server recompiles<br/>→ new ContentHash = 0xBBBB"]
|
||||
RECOMP -.-> MUPD["Manifest updated:<br/>same key → 0xBBBB"]
|
||||
MUPD -.-> NEXT["Next frame: manifest read<br/>picks up 0xBBBB automatically"]
|
||||
end
|
||||
|
||||
style RECOMP fill:#0f3460,stroke:#533483,color:#eee
|
||||
style MUPD fill:#0f3460,stroke:#533483,color:#eee
|
||||
style NEXT fill:#16213e,stroke:#0f3460,color:#eee
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **The `Shader` struct never changes.** No unload, no recreate, no generation counter bump. The manifest is the *only* mutable state, and it lives on disk, outside the runtime's object graph. The runtime just reads it.
|
||||
|
||||
---
|
||||
|
||||
## 7. The Two Interfaces That Make This Work
|
||||
|
||||
Only two abstractions are needed in `Ghost.Graphics` to support the full pipeline:
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class IShaderCompilationBridge {
|
||||
<<interface>>
|
||||
+TryGetBytecode(manifestKey: ulong, out bytecode: ReadOnlyMemory~byte~) bool
|
||||
+IsCompiling(manifestKey: ulong) bool
|
||||
}
|
||||
|
||||
class RuntimeStub {
|
||||
+TryGetBytecode() → always from ShaderLibrary cache
|
||||
+IsCompiling() → always false
|
||||
}
|
||||
|
||||
class EditorImplementation {
|
||||
-NamedPipeClient _serverConnection
|
||||
+TryGetBytecode() → check manifest, read cache
|
||||
+IsCompiling() → query server status
|
||||
}
|
||||
|
||||
IShaderCompilationBridge <|.. RuntimeStub : "Shipped game"
|
||||
IShaderCompilationBridge <|.. EditorImplementation : "Editor mode"
|
||||
|
||||
class ShaderLibrary {
|
||||
-string _cacheDirectory
|
||||
+GetCache(contentHash: ulong) Result~bytes~
|
||||
+GetFromManifest(manifestKey: ulong) Result~bytes~
|
||||
}
|
||||
|
||||
EditorImplementation --> ShaderLibrary : reads cache
|
||||
RuntimeStub --> ShaderLibrary : reads cache
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> `RenderGraphContext` doesn't talk to the bridge directly. It talks to `ShaderLibrary`, which internally consults the bridge on cache miss. This keeps the rendering code clean — it never sees compilation status. It just gets bytecode or it doesn't.
|
||||
|
||||
---
|
||||
|
||||
## 8. Build Pipeline — How Shipped Games Work
|
||||
|
||||
For completeness, here's how the same architecture handles builds:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph BuildTime["Build Pipeline"]
|
||||
SCAN["Scan all materials<br/>in scenes/assets"] --> COLLECT["Collect all referenced<br/>(GUID, pass, variant) tuples"]
|
||||
COLLECT --> COMPILE["Compile all variants<br/>via ShaderServer"]
|
||||
COMPILE --> PACK["Package manifest +<br/>bytecode blobs into<br/>game data archive"]
|
||||
end
|
||||
|
||||
subgraph ShippedGame["Runtime (shipped game)"]
|
||||
LOAD["Load manifest +<br/>bytecode from archive"] --> LIB["ShaderLibrary<br/>(read-only, all variants pre-cached)"]
|
||||
LIB --> MISS{"Cache miss?"}
|
||||
MISS -- "Never<br/>(if build is correct)" --> OK["Create PSO normally"]
|
||||
MISS -- "Somehow yes<br/>(bug or modding)" --> ERR["Error PSO<br/>+ log warning"]
|
||||
end
|
||||
|
||||
BuildTime --> ShippedGame
|
||||
|
||||
style BuildTime fill:#1a1a2e,stroke:#0f3460,color:#eee
|
||||
style ShippedGame fill:#16213e,stroke:#533483,color:#eee
|
||||
```
|
||||
|
||||
The beauty: **the same `ShaderLibrary` and `PipelineLibrary` code runs in both editor and shipped game**. The only difference is whether `IShaderCompilationBridge` is the editor implementation or the runtime stub.
|
||||
|
||||
---
|
||||
|
||||
## Summary of Key Design Decisions
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|----------|-----------|
|
||||
| 1 | Stable GUID identity, not content hash | Shader struct never needs recreation on edit |
|
||||
| 2 | Content-addressed cache | Deduplication, easy invalidation, git-friendly |
|
||||
| 3 | Manifest as the bridge | Decouples identity from compiled output cleanly |
|
||||
| 4 | Keep old PSO during recompile | Zero flicker, seamless UX |
|
||||
| 5 | Separate compiler process | Crash isolation, independent resource budget |
|
||||
| 6 | Two-method interface in runtime | Minimal coupling, easy to stub for shipped game |
|
||||
| 7 | Deferred PSO release via fence | Prevents GPU use-after-free |
|
||||
| 8 | Same code path for editor + shipped | Fewer bugs, one pipeline to maintain |
|
||||
@@ -1,669 +0,0 @@
|
||||
# DockLayout Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Create a fully-featured, dynamically splittable, multi-window docking layout system using WinUI 3.
|
||||
|
||||
**Architecture:** A C# data model (node tree) drives the recursive generation of XAML `Grid` and `NavigationTabView` controls. Drag and drop events mutate the node tree, and the UI automatically reflects the changes.
|
||||
|
||||
**Tech Stack:** C#, WinUI 3, CommunityToolkit.Mvvm (for ObservableObject).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Create Core Data Models
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockNode.cs`
|
||||
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs`
|
||||
- Create: `src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockPanelNode.cs`
|
||||
|
||||
- [ ] **Step 1: Write `DockNode` base class**
|
||||
|
||||
```csharp
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
||||
|
||||
public abstract partial class DockNode : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private DockGroupNode? _parent;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Write `DockGroupNode` class**
|
||||
|
||||
```csharp
|
||||
using System.Collections.ObjectModel;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
||||
|
||||
public partial class DockGroupNode : DockNode
|
||||
{
|
||||
[ObservableProperty]
|
||||
private Orientation _orientation = Orientation.Horizontal;
|
||||
|
||||
public ObservableCollection<DockNode> Children { get; } = new();
|
||||
|
||||
public void AddChild(DockNode node)
|
||||
{
|
||||
node.Parent = this;
|
||||
Children.Add(node);
|
||||
}
|
||||
|
||||
public void RemoveChild(DockNode node)
|
||||
{
|
||||
if (Children.Remove(node))
|
||||
{
|
||||
node.Parent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Write `DockPanelNode` class**
|
||||
|
||||
```csharp
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
||||
|
||||
public partial class DockPanelNode : DockNode
|
||||
{
|
||||
public ObservableCollection<object> Items { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
private int _selectedIndex = -1;
|
||||
|
||||
[ObservableProperty]
|
||||
private object? _selectedItem;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/*
|
||||
git commit -m "feat(dock): add core data models for docking system"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement Tree Renderer in XAML
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.xaml`
|
||||
|
||||
- [ ] **Step 1: Add DependencyProperty for Root in DockLayout.cs**
|
||||
|
||||
```csharp
|
||||
using Ghost.Editor.Core.Controls.Internal.Docking;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.View.Controls;
|
||||
|
||||
public sealed partial class DockLayout : Control
|
||||
{
|
||||
public DockLayout()
|
||||
{
|
||||
DefaultStyleKey = typeof(DockLayout);
|
||||
}
|
||||
|
||||
public DockGroupNode? Root
|
||||
{
|
||||
get => (DockGroupNode?)GetValue(RootProperty);
|
||||
set => SetValue(RootProperty, value);
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty RootProperty =
|
||||
DependencyProperty.Register("Root", typeof(DockGroupNode), typeof(DockLayout), new PropertyMetadata(null, OnRootChanged));
|
||||
|
||||
private static void OnRootChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is DockLayout layout)
|
||||
{
|
||||
layout.RenderTree();
|
||||
}
|
||||
}
|
||||
|
||||
private void RenderTree()
|
||||
{
|
||||
if (GetTemplateChild("PART_RootGrid") is Grid rootGrid)
|
||||
{
|
||||
rootGrid.Children.Clear();
|
||||
if (Root != null)
|
||||
{
|
||||
var ui = CreateUIForNode(Root);
|
||||
rootGrid.Children.Add(ui);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private UIElement CreateUIForNode(DockNode node)
|
||||
{
|
||||
if (node is DockGroupNode groupNode)
|
||||
{
|
||||
// Simple visualizer for now, full grid logic in next step
|
||||
var grid = new Grid();
|
||||
foreach (var child in groupNode.Children)
|
||||
{
|
||||
grid.Children.Add(CreateUIForNode(child));
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
else if (node is DockPanelNode panelNode)
|
||||
{
|
||||
return new Ghost.Editor.Controls.NavigationTabView
|
||||
{
|
||||
ItemsSource = panelNode.Items,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch
|
||||
};
|
||||
}
|
||||
return new Grid(); // Fallback
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate()
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
RenderTree();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Define ControlTemplate in DockLayout.xaml**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Ghost.Editor.View.Controls">
|
||||
<Style TargetType="local:DockLayout">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:DockLayout">
|
||||
<Grid x:Name="PART_RootGrid" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.*
|
||||
git commit -m "feat(dock): implement basic recursive tree renderer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement DockGroupNode Grid Builder
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
||||
|
||||
- [ ] **Step 1: Replace `CreateUIForNode` Group logic to generate Columns/Rows and GridSplitters**
|
||||
|
||||
```csharp
|
||||
private UIElement CreateUIForNode(DockNode node)
|
||||
{
|
||||
if (node is DockGroupNode groupNode)
|
||||
{
|
||||
var grid = new Grid();
|
||||
bool isHorizontal = groupNode.Orientation == Orientation.Horizontal;
|
||||
int childCount = groupNode.Children.Count;
|
||||
|
||||
for (int i = 0; i < childCount; i++)
|
||||
{
|
||||
var childNode = groupNode.Children[i];
|
||||
var childUI = CreateUIForNode(childNode);
|
||||
|
||||
if (isHorizontal)
|
||||
{
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
Grid.SetColumn((FrameworkElement)childUI, i * 2);
|
||||
}
|
||||
else
|
||||
{
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
Grid.SetRow((FrameworkElement)childUI, i * 2);
|
||||
}
|
||||
|
||||
grid.Children.Add(childUI);
|
||||
|
||||
// Add GridSplitter between children
|
||||
if (i < childCount - 1)
|
||||
{
|
||||
if (isHorizontal)
|
||||
{
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter { Width = 4, HorizontalAlignment = HorizontalAlignment.Center, ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Columns };
|
||||
Grid.SetColumn(splitter, (i * 2) + 1);
|
||||
grid.Children.Add(splitter);
|
||||
}
|
||||
else
|
||||
{
|
||||
grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter { Height = 4, VerticalAlignment = VerticalAlignment.Center, ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Rows };
|
||||
Grid.SetRow(splitter, (i * 2) + 1);
|
||||
grid.Children.Add(splitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Listen to CollectionChanged to trigger re-render
|
||||
groupNode.Children.CollectionChanged -= GroupNode_Children_CollectionChanged;
|
||||
groupNode.Children.CollectionChanged += GroupNode_Children_CollectionChanged;
|
||||
|
||||
return grid;
|
||||
}
|
||||
else if (node is DockPanelNode panelNode)
|
||||
{
|
||||
var tabView = new Ghost.Editor.Controls.NavigationTabView
|
||||
{
|
||||
TabItemsSource = panelNode.Items,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
CanDragTabs = true,
|
||||
AllowDrop = true,
|
||||
Tag = panelNode // Store reference to data node
|
||||
};
|
||||
return tabView;
|
||||
}
|
||||
return new Grid();
|
||||
}
|
||||
|
||||
private void GroupNode_Children_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
// For MVP, just re-render the whole tree when a group changes structure
|
||||
RenderTree();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
|
||||
git commit -m "feat(dock): implement grid and gridsplitter generation for groups"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Setup Visual Drop Target Overlay
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.xaml`
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
||||
|
||||
- [ ] **Step 1: Add Drop Overlay to ControlTemplate**
|
||||
|
||||
```xml
|
||||
<ControlTemplate TargetType="local:DockLayout">
|
||||
<Grid>
|
||||
<Grid x:Name="PART_RootGrid" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}" />
|
||||
<Border x:Name="PART_DropTargetOverlay"
|
||||
Background="#660078D4"
|
||||
BorderBrush="#FF0078D4"
|
||||
BorderThickness="2"
|
||||
Visibility="Collapsed"
|
||||
IsHitTestVisible="False" />
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add Fields and ApplyTemplate logic in DockLayout.cs**
|
||||
|
||||
```csharp
|
||||
private Border? _dropTargetOverlay;
|
||||
|
||||
protected override void OnApplyTemplate()
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
_dropTargetOverlay = GetTemplateChild("PART_DropTargetOverlay") as Border;
|
||||
RenderTree();
|
||||
}
|
||||
|
||||
// Helper enum for later
|
||||
public enum DockPosition { Center, Top, Bottom, Left, Right, None }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.*
|
||||
git commit -m "feat(dock): add visual drop target overlay"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Implement Drag and Drop Calculations (Highlighting)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
||||
|
||||
- [ ] **Step 1: Attach TabView Drag Events in `CreateUIForNode`**
|
||||
|
||||
```csharp
|
||||
else if (node is DockPanelNode panelNode)
|
||||
{
|
||||
var tabView = new Ghost.Editor.Controls.NavigationTabView
|
||||
{
|
||||
TabItemsSource = panelNode.Items,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
CanDragTabs = true,
|
||||
AllowDrop = true,
|
||||
Tag = panelNode // Store reference to data node
|
||||
};
|
||||
|
||||
tabView.DragOver += TabView_DragOver;
|
||||
tabView.DragLeave += TabView_DragLeave;
|
||||
tabView.Drop += TabView_Drop;
|
||||
tabView.TabDragStarting += TabView_TabDragStarting;
|
||||
|
||||
return tabView;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement Drag Handling Logic**
|
||||
|
||||
```csharp
|
||||
private object? _draggedItem;
|
||||
private DockPanelNode? _sourceNode;
|
||||
private DockPosition _currentDropPosition = DockPosition.None;
|
||||
|
||||
private void TabView_TabDragStarting(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDragStartingEventArgs args)
|
||||
{
|
||||
_draggedItem = args.Item;
|
||||
_sourceNode = sender.Tag as DockPanelNode;
|
||||
args.Data.Properties.Add("DockTab", _draggedItem); // Identify our drag
|
||||
}
|
||||
|
||||
private void TabView_DragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (e.DataView.Properties.ContainsKey("DockTab") && sender is FrameworkElement targetElement)
|
||||
{
|
||||
e.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
|
||||
|
||||
var position = e.GetPosition(targetElement);
|
||||
double width = targetElement.ActualWidth;
|
||||
double height = targetElement.ActualHeight;
|
||||
|
||||
double edgeThreshold = 0.25; // 25% of edge triggers split
|
||||
|
||||
if (position.X < width * edgeThreshold) _currentDropPosition = DockPosition.Left;
|
||||
else if (position.X > width * (1 - edgeThreshold)) _currentDropPosition = DockPosition.Right;
|
||||
else if (position.Y < height * edgeThreshold) _currentDropPosition = DockPosition.Top;
|
||||
else if (position.Y > height * (1 - edgeThreshold)) _currentDropPosition = DockPosition.Bottom;
|
||||
else _currentDropPosition = DockPosition.Center;
|
||||
|
||||
UpdateDropOverlay(targetElement, _currentDropPosition);
|
||||
}
|
||||
}
|
||||
|
||||
private void TabView_DragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
if (_dropTargetOverlay != null)
|
||||
{
|
||||
_dropTargetOverlay.Visibility = Visibility.Collapsed;
|
||||
_currentDropPosition = DockPosition.None;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDropOverlay(FrameworkElement targetElement, DockPosition position)
|
||||
{
|
||||
if (_dropTargetOverlay == null) return;
|
||||
if (position == DockPosition.None)
|
||||
{
|
||||
_dropTargetOverlay.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
var transform = targetElement.TransformToVisual(this);
|
||||
var bounds = transform.TransformBounds(new Windows.Foundation.Rect(0, 0, targetElement.ActualWidth, targetElement.ActualHeight));
|
||||
|
||||
_dropTargetOverlay.Visibility = Visibility.Visible;
|
||||
_dropTargetOverlay.Width = double.NaN;
|
||||
_dropTargetOverlay.Height = double.NaN;
|
||||
|
||||
switch (position)
|
||||
{
|
||||
case DockPosition.Center:
|
||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
|
||||
break;
|
||||
case DockPosition.Left:
|
||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - (bounds.Left + bounds.Width / 2), ActualHeight - bounds.Bottom);
|
||||
break;
|
||||
case DockPosition.Right:
|
||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left + bounds.Width / 2, bounds.Top, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
|
||||
break;
|
||||
case DockPosition.Top:
|
||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top, ActualWidth - bounds.Right, ActualHeight - (bounds.Top + bounds.Height / 2));
|
||||
break;
|
||||
case DockPosition.Bottom:
|
||||
_dropTargetOverlay.Margin = new Thickness(bounds.Left, bounds.Top + bounds.Height / 2, ActualWidth - bounds.Right, ActualHeight - bounds.Bottom);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
|
||||
git commit -m "feat(dock): implement drop highlight calculations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Implement Dropping (Data Tree Mutation)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
||||
|
||||
- [ ] **Step 1: Implement `TabView_Drop` logic**
|
||||
|
||||
```csharp
|
||||
private void TabView_Drop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed;
|
||||
|
||||
if (_draggedItem == null || _sourceNode == null || !(sender is FrameworkElement targetElement) || !(targetElement.Tag is DockPanelNode targetNode))
|
||||
return;
|
||||
|
||||
if (_sourceNode == targetNode && _currentDropPosition == DockPosition.Center)
|
||||
return; // Reordering within same tab is handled natively by TabView
|
||||
|
||||
// 1. Remove from source
|
||||
_sourceNode.Items.Remove(_draggedItem);
|
||||
CleanupEmptyNodes(_sourceNode);
|
||||
|
||||
// 2. Add to target
|
||||
if (_currentDropPosition == DockPosition.Center)
|
||||
{
|
||||
targetNode.Items.Add(_draggedItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Split scenario
|
||||
var parentGroup = targetNode.Parent;
|
||||
if (parentGroup != null)
|
||||
{
|
||||
int index = parentGroup.Children.IndexOf(targetNode);
|
||||
parentGroup.Children.RemoveAt(index);
|
||||
|
||||
var newGroup = new DockGroupNode
|
||||
{
|
||||
Orientation = (_currentDropPosition == DockPosition.Left || _currentDropPosition == DockPosition.Right) ? Orientation.Horizontal : Orientation.Vertical
|
||||
};
|
||||
|
||||
var newPanel = new DockPanelNode();
|
||||
newPanel.Items.Add(_draggedItem);
|
||||
|
||||
if (_currentDropPosition == DockPosition.Left || _currentDropPosition == DockPosition.Top)
|
||||
{
|
||||
newGroup.AddChild(newPanel);
|
||||
newGroup.AddChild(targetNode);
|
||||
}
|
||||
else
|
||||
{
|
||||
newGroup.AddChild(targetNode);
|
||||
newGroup.AddChild(newPanel);
|
||||
}
|
||||
|
||||
parentGroup.Children.Insert(index, newGroup);
|
||||
}
|
||||
}
|
||||
|
||||
_draggedItem = null;
|
||||
_sourceNode = null;
|
||||
_currentDropPosition = DockPosition.None;
|
||||
}
|
||||
|
||||
private void CleanupEmptyNodes(DockPanelNode panelNode)
|
||||
{
|
||||
if (panelNode.Items.Count > 0) return;
|
||||
|
||||
var parentGroup = panelNode.Parent;
|
||||
if (parentGroup != null)
|
||||
{
|
||||
parentGroup.RemoveChild(panelNode);
|
||||
|
||||
// If group only has 1 child left, collapse it
|
||||
if (parentGroup.Children.Count == 1)
|
||||
{
|
||||
var onlyChild = parentGroup.Children[0];
|
||||
var grandParent = parentGroup.Parent;
|
||||
if (grandParent != null)
|
||||
{
|
||||
int index = grandParent.Children.IndexOf(parentGroup);
|
||||
parentGroup.RemoveChild(onlyChild);
|
||||
grandParent.Children.RemoveAt(index);
|
||||
grandParent.Children.Insert(index, onlyChild);
|
||||
}
|
||||
else if (parentGroup == Root)
|
||||
{
|
||||
// If root is collapsing, the only child becomes the new root
|
||||
parentGroup.RemoveChild(onlyChild);
|
||||
if (onlyChild is DockGroupNode newRootGroup)
|
||||
{
|
||||
Root = newRootGroup;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Wrap panel in a new group to keep Root as a GroupNode
|
||||
var wrapperGroup = new DockGroupNode();
|
||||
wrapperGroup.AddChild(onlyChild);
|
||||
Root = wrapperGroup;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
|
||||
git commit -m "feat(dock): implement tree mutation on drop and empty node cleanup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Implement Window Tear-Off (TabDroppedOutside)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Windows/DockWindow.xaml.cs`
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/DockLayout.cs`
|
||||
|
||||
- [ ] **Step 1: Create `DockWindow` wrapper**
|
||||
|
||||
`DockWindow.xaml`:
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<winex:WindowEx
|
||||
x:Class="Ghost.Editor.View.Windows.DockWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:Ghost.Editor.View.Controls"
|
||||
xmlns:winex="using:WinUIEx">
|
||||
<Grid>
|
||||
<controls:DockLayout x:Name="PART_DockLayout" />
|
||||
</Grid>
|
||||
</winex:WindowEx>
|
||||
```
|
||||
|
||||
`DockWindow.xaml.cs`:
|
||||
```csharp
|
||||
using Ghost.Editor.Core.Controls.Internal.Docking;
|
||||
using WinUIEx;
|
||||
|
||||
namespace Ghost.Editor.View.Windows;
|
||||
|
||||
public sealed partial class DockWindow : WindowEx
|
||||
{
|
||||
public DockWindow(object initialTabContent)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Setup initial single panel layout
|
||||
var rootGroup = new DockGroupNode();
|
||||
var panel = new DockPanelNode();
|
||||
panel.Items.Add(initialTabContent);
|
||||
rootGroup.AddChild(panel);
|
||||
|
||||
PART_DockLayout.Root = rootGroup;
|
||||
|
||||
// Optional: Titlebar setup etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Handle `TabDroppedOutside` in `DockLayout.cs`**
|
||||
|
||||
Modify `CreateUIForNode` in `DockLayout.cs`:
|
||||
```csharp
|
||||
// ... inside CreateUIForNode for DockPanelNode ...
|
||||
tabView.TabDragStarting += TabView_TabDragStarting;
|
||||
tabView.TabDroppedOutside += TabView_TabDroppedOutside; // NEW
|
||||
|
||||
return tabView;
|
||||
```
|
||||
|
||||
Add event handler:
|
||||
```csharp
|
||||
private void TabView_TabDroppedOutside(Microsoft.UI.Xaml.Controls.TabView sender, Microsoft.UI.Xaml.Controls.TabViewTabDroppedOutsideEventArgs args)
|
||||
{
|
||||
if (_sourceNode != null && _draggedItem != null)
|
||||
{
|
||||
// Remove from current tree
|
||||
_sourceNode.Items.Remove(_draggedItem);
|
||||
CleanupEmptyNodes(_sourceNode);
|
||||
|
||||
// Create new window
|
||||
var newWindow = new Ghost.Editor.View.Windows.DockWindow(_draggedItem);
|
||||
newWindow.Activate();
|
||||
|
||||
_draggedItem = null;
|
||||
_sourceNode = null;
|
||||
_currentDropPosition = DockPosition.None;
|
||||
if (_dropTargetOverlay != null) _dropTargetOverlay.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Windows/DockWindow.* src/Editor/Ghost.Editor/View/Controls/DockLayout.cs
|
||||
git commit -m "feat(dock): implement tab tear-off to new window"
|
||||
```
|
||||
@@ -1,724 +0,0 @@
|
||||
# Docking Layout Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a custom WinUI 3 docking layout system for GhostEngine's editor with Unity/Blender-style region highlighting and dynamic tab creation.
|
||||
|
||||
**Architecture:** A UI-driven approach where custom controls (`DockingLayout`, `DockPanel`, `DockGroup`, `DockDocument`) manage their own state and visual tree. Drag-and-drop manipulates the visual tree directly.
|
||||
|
||||
**Tech Stack:** C#, WinUI 3, Windows App SDK
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Core Enums and Base Classes
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs`
|
||||
|
||||
- [ ] **Step 1: Create Enums**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs`:
|
||||
```csharp
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
public enum DockTarget
|
||||
{
|
||||
Center,
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create DockModule base class**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs`:
|
||||
```csharp
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
public abstract class DockModule : Control
|
||||
{
|
||||
public DockContainer? Owner { get; internal set; }
|
||||
public DockingLayout? Root { get; internal set; }
|
||||
|
||||
public void Detach()
|
||||
{
|
||||
Owner?.Children.Remove(this);
|
||||
Owner = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create DockContainer base class**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs`:
|
||||
```csharp
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
public abstract class DockContainer : DockModule
|
||||
{
|
||||
public ObservableCollection<DockModule> Children { get; } = new();
|
||||
|
||||
protected DockContainer()
|
||||
{
|
||||
Children.CollectionChanged += OnChildrenChanged;
|
||||
}
|
||||
|
||||
private void OnChildrenChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.OldItems != null)
|
||||
{
|
||||
foreach (DockModule module in e.OldItems)
|
||||
{
|
||||
module.Owner = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.NewItems != null)
|
||||
{
|
||||
foreach (DockModule module in e.NewItems)
|
||||
{
|
||||
module.Owner = this;
|
||||
module.Root = Root;
|
||||
}
|
||||
}
|
||||
|
||||
OnChildrenUpdated();
|
||||
}
|
||||
|
||||
protected virtual void OnChildrenUpdated() { }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/Enums.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockModule.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockContainer.cs
|
||||
git commit -m "feat(docking): add core enums and base classes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: DockDocument and DockGroup
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml`
|
||||
|
||||
- [ ] **Step 1: Create DockDocument**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs`:
|
||||
```csharp
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
public class DockDocument : DockModule
|
||||
{
|
||||
public static readonly DependencyProperty TitleProperty = DependencyProperty.Register(
|
||||
nameof(Title), typeof(string), typeof(DockDocument), new PropertyMetadata(string.Empty));
|
||||
|
||||
public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(
|
||||
nameof(Content), typeof(object), typeof(DockDocument), new PropertyMetadata(null));
|
||||
|
||||
public string Title
|
||||
{
|
||||
get => (string)GetValue(TitleProperty);
|
||||
set => SetValue(TitleProperty, value);
|
||||
}
|
||||
|
||||
public object Content
|
||||
{
|
||||
get => GetValue(ContentProperty);
|
||||
set => SetValue(ContentProperty, value);
|
||||
}
|
||||
|
||||
public DockDocument()
|
||||
{
|
||||
DefaultStyleKey = typeof(DockDocument);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create DockGroup XAML**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml`:
|
||||
```xml
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
|
||||
|
||||
<Style TargetType="local:DockGroup">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:DockGroup">
|
||||
<Grid>
|
||||
<TabView x:Name="PART_TabView"
|
||||
IsAddTabButtonVisible="False"
|
||||
CanDragTabs="True"
|
||||
CanReorderTabs="True"
|
||||
AllowDrop="True">
|
||||
</TabView>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create DockGroup Code-Behind**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`:
|
||||
```csharp
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
[TemplatePart(Name = "PART_TabView", Type = typeof(TabView))]
|
||||
public class DockGroup : DockContainer
|
||||
{
|
||||
private TabView? _tabView;
|
||||
|
||||
public DockGroup()
|
||||
{
|
||||
DefaultStyleKey = typeof(DockGroup);
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate()
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
_tabView = GetTemplateChild("PART_TabView") as TabView;
|
||||
UpdateTabs();
|
||||
}
|
||||
|
||||
protected override void OnChildrenUpdated()
|
||||
{
|
||||
UpdateTabs();
|
||||
}
|
||||
|
||||
private void UpdateTabs()
|
||||
{
|
||||
if (_tabView == null) return;
|
||||
|
||||
_tabView.TabItems.Clear();
|
||||
foreach (var child in Children)
|
||||
{
|
||||
if (child is DockDocument doc)
|
||||
{
|
||||
var tabItem = new TabViewItem
|
||||
{
|
||||
Header = doc.Title,
|
||||
Content = doc.Content,
|
||||
Tag = doc
|
||||
};
|
||||
_tabView.TabItems.Add(tabItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockDocument.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.xaml
|
||||
git commit -m "feat(docking): add DockDocument and DockGroup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: DockPanel
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml`
|
||||
|
||||
- [ ] **Step 1: Create DockPanel XAML**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml`:
|
||||
```xml
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
|
||||
|
||||
<Style TargetType="local:DockPanel">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:DockPanel">
|
||||
<Grid x:Name="PART_Grid" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create DockPanel Code-Behind**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs`:
|
||||
```csharp
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using CommunityToolkit.WinUI.Controls;
|
||||
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
[TemplatePart(Name = "PART_Grid", Type = typeof(Grid))]
|
||||
public class DockPanel : DockContainer
|
||||
{
|
||||
public static readonly DependencyProperty OrientationProperty = DependencyProperty.Register(
|
||||
nameof(Orientation), typeof(Orientation), typeof(DockPanel), new PropertyMetadata(Orientation.Horizontal, OnOrientationChanged));
|
||||
|
||||
public Orientation Orientation
|
||||
{
|
||||
get => (Orientation)GetValue(OrientationProperty);
|
||||
set => SetValue(OrientationProperty, value);
|
||||
}
|
||||
|
||||
private Grid? _grid;
|
||||
|
||||
public DockPanel()
|
||||
{
|
||||
DefaultStyleKey = typeof(DockPanel);
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate()
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
_grid = GetTemplateChild("PART_Grid") as Grid;
|
||||
UpdateLayoutStructure();
|
||||
}
|
||||
|
||||
protected override void OnChildrenUpdated()
|
||||
{
|
||||
UpdateLayoutStructure();
|
||||
}
|
||||
|
||||
private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
((DockPanel)d).UpdateLayoutStructure();
|
||||
}
|
||||
|
||||
private void UpdateLayoutStructure()
|
||||
{
|
||||
if (_grid == null) return;
|
||||
|
||||
_grid.Children.Clear();
|
||||
_grid.RowDefinitions.Clear();
|
||||
_grid.ColumnDefinitions.Clear();
|
||||
|
||||
if (Children.Count == 0) return;
|
||||
|
||||
if (Orientation == Orientation.Horizontal)
|
||||
{
|
||||
for (int i = 0; i < Children.Count; i++)
|
||||
{
|
||||
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
var child = Children[i];
|
||||
Grid.SetColumn(child, i * 2);
|
||||
_grid.Children.Add(child);
|
||||
|
||||
if (i < Children.Count - 1)
|
||||
{
|
||||
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Columns, Width = 4 };
|
||||
Grid.SetColumn(splitter, i * 2 + 1);
|
||||
_grid.Children.Add(splitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < Children.Count; i++)
|
||||
{
|
||||
_grid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
|
||||
var child = Children[i];
|
||||
Grid.SetRow(child, i * 2);
|
||||
_grid.Children.Add(child);
|
||||
|
||||
if (i < Children.Count - 1)
|
||||
{
|
||||
_grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
|
||||
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Rows, Height = 4 };
|
||||
Grid.SetRow(splitter, i * 2 + 1);
|
||||
_grid.Children.Add(splitter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockPanel.xaml
|
||||
git commit -m "feat(docking): add DockPanel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: DockRegionHighlight and DockingLayout
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml`
|
||||
|
||||
- [ ] **Step 1: Create DockRegionHighlight XAML**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml`:
|
||||
```xml
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
|
||||
|
||||
<Style TargetType="local:DockRegionHighlight">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:DockRegionHighlight">
|
||||
<Border Background="#400078D7" BorderBrush="#800078D7" BorderThickness="2" CornerRadius="4" />
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create DockRegionHighlight Code-Behind**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs`:
|
||||
```csharp
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
public class DockRegionHighlight : Control
|
||||
{
|
||||
public DockRegionHighlight()
|
||||
{
|
||||
DefaultStyleKey = typeof(DockRegionHighlight);
|
||||
IsHitTestVisible = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create DockingLayout XAML**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml`:
|
||||
```xml
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Ghost.Editor.View.Controls.Docking">
|
||||
|
||||
<Style TargetType="local:DockingLayout">
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:DockingLayout">
|
||||
<Grid>
|
||||
<ContentPresenter x:Name="PART_Content" Content="{TemplateBinding RootPanel}" />
|
||||
<Canvas x:Name="PART_OverlayCanvas" IsHitTestVisible="False">
|
||||
<local:DockRegionHighlight x:Name="PART_Highlight" Visibility="Collapsed" />
|
||||
</Canvas>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create DockingLayout Code-Behind**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`:
|
||||
```csharp
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
[TemplatePart(Name = "PART_Content", Type = typeof(ContentPresenter))]
|
||||
[TemplatePart(Name = "PART_OverlayCanvas", Type = typeof(Canvas))]
|
||||
[TemplatePart(Name = "PART_Highlight", Type = typeof(DockRegionHighlight))]
|
||||
public class DockingLayout : Control
|
||||
{
|
||||
public static readonly DependencyProperty RootPanelProperty = DependencyProperty.Register(
|
||||
nameof(RootPanel), typeof(DockPanel), typeof(DockingLayout), new PropertyMetadata(null, OnRootPanelChanged));
|
||||
|
||||
public DockPanel? RootPanel
|
||||
{
|
||||
get => (DockPanel?)GetValue(RootPanelProperty);
|
||||
set => SetValue(RootPanelProperty, value);
|
||||
}
|
||||
|
||||
private Canvas? _overlayCanvas;
|
||||
private DockRegionHighlight? _highlight;
|
||||
|
||||
public DockingLayout()
|
||||
{
|
||||
DefaultStyleKey = typeof(DockingLayout);
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate()
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
_overlayCanvas = GetTemplateChild("PART_OverlayCanvas") as Canvas;
|
||||
_highlight = GetTemplateChild("PART_Highlight") as DockRegionHighlight;
|
||||
}
|
||||
|
||||
private static void OnRootPanelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is DockingLayout layout && e.NewValue is DockPanel panel)
|
||||
{
|
||||
panel.Root = layout;
|
||||
}
|
||||
}
|
||||
|
||||
public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null)
|
||||
{
|
||||
if (RootPanel == null)
|
||||
{
|
||||
RootPanel = new DockPanel();
|
||||
}
|
||||
|
||||
if (targetGroup == null)
|
||||
{
|
||||
if (RootPanel.Children.Count == 0)
|
||||
{
|
||||
var group = new DockGroup();
|
||||
group.Children.Add(document);
|
||||
RootPanel.Children.Add(group);
|
||||
return;
|
||||
}
|
||||
targetGroup = RootPanel.Children[0] as DockGroup;
|
||||
}
|
||||
|
||||
if (targetGroup != null)
|
||||
{
|
||||
targetGroup.Children.Add(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockRegionHighlight.xaml src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.xaml
|
||||
git commit -m "feat(docking): add DockRegionHighlight and DockingLayout"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Drag and Drop Logic
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs`
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
|
||||
|
||||
- [ ] **Step 1: Implement Drag and Drop in DockGroup**
|
||||
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs` to handle drag events on the TabView:
|
||||
```csharp
|
||||
// Add to OnApplyTemplate:
|
||||
if (_tabView != null)
|
||||
{
|
||||
_tabView.TabDragStarting += OnTabDragStarting;
|
||||
_tabView.TabDroppedOutside += OnTabDroppedOutside;
|
||||
_tabView.DragOver += OnDragOver;
|
||||
_tabView.Drop += OnDrop;
|
||||
_tabView.DragLeave += OnDragLeave;
|
||||
}
|
||||
|
||||
// Add methods:
|
||||
private void OnTabDragStarting(TabView sender, TabViewTabDragStartingEventArgs args)
|
||||
{
|
||||
if (args.Tab.Tag is DockDocument doc)
|
||||
{
|
||||
args.Data.Properties.Add("DockDocument", doc);
|
||||
doc.Detach();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTabDroppedOutside(TabView sender, TabViewTabDroppedOutsideEventArgs args)
|
||||
{
|
||||
if (args.Tab.Tag is DockDocument doc)
|
||||
{
|
||||
Root?.CreateFloatingWindow(doc);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
if (e.DataView.Properties.ContainsKey("DockDocument"))
|
||||
{
|
||||
e.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
|
||||
Root?.ShowHighlight(this, e.GetPosition(this));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDrop(object sender, DragEventArgs e)
|
||||
{
|
||||
if (e.DataView.Properties.TryGetValue("DockDocument", out var obj) && obj is DockDocument doc)
|
||||
{
|
||||
Root?.HandleDrop(doc, this, e.GetPosition(this));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
Root?.HideHighlight();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement Highlight and Drop in DockingLayout**
|
||||
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs` to add `ShowHighlight`, `HideHighlight`, `HandleDrop`, and `CreateFloatingWindow`:
|
||||
```csharp
|
||||
// Add methods:
|
||||
internal void ShowHighlight(DockGroup targetGroup, Windows.Foundation.Point position)
|
||||
{
|
||||
if (_highlight == null || _overlayCanvas == null) return;
|
||||
|
||||
_highlight.Visibility = Visibility.Visible;
|
||||
var target = CalculateDockTarget(targetGroup, position);
|
||||
|
||||
// Calculate rect based on target (simplified for brevity, needs actual math based on targetGroup's ActualWidth/Height)
|
||||
double width = targetGroup.ActualWidth;
|
||||
double height = targetGroup.ActualHeight;
|
||||
double x = 0, y = 0;
|
||||
|
||||
switch (target)
|
||||
{
|
||||
case DockTarget.Left: width /= 2; break;
|
||||
case DockTarget.Right: width /= 2; x = width; break;
|
||||
case DockTarget.Top: height /= 2; break;
|
||||
case DockTarget.Bottom: height /= 2; y = height; break;
|
||||
case DockTarget.Center: break;
|
||||
}
|
||||
|
||||
var transform = targetGroup.TransformToVisual(_overlayCanvas);
|
||||
var point = transform.TransformPoint(new Windows.Foundation.Point(x, y));
|
||||
|
||||
Canvas.SetLeft(_highlight, point.X);
|
||||
Canvas.SetTop(_highlight, point.Y);
|
||||
_highlight.Width = width;
|
||||
_highlight.Height = height;
|
||||
}
|
||||
|
||||
internal void HideHighlight()
|
||||
{
|
||||
if (_highlight != null) _highlight.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
internal void HandleDrop(DockDocument doc, DockGroup targetGroup, Windows.Foundation.Point position)
|
||||
{
|
||||
HideHighlight();
|
||||
var target = CalculateDockTarget(targetGroup, position);
|
||||
|
||||
if (target == DockTarget.Center)
|
||||
{
|
||||
targetGroup.Children.Add(doc);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Split logic: create new DockPanel, move targetGroup and doc into it
|
||||
var parentPanel = targetGroup.Owner as DockPanel;
|
||||
if (parentPanel != null)
|
||||
{
|
||||
int index = parentPanel.Children.IndexOf(targetGroup);
|
||||
targetGroup.Detach();
|
||||
|
||||
var newPanel = new DockPanel { Orientation = (target == DockTarget.Left || target == DockTarget.Right) ? Orientation.Horizontal : Orientation.Vertical };
|
||||
var newGroup = new DockGroup();
|
||||
newGroup.Children.Add(doc);
|
||||
|
||||
if (target == DockTarget.Left || target == DockTarget.Top)
|
||||
{
|
||||
newPanel.Children.Add(newGroup);
|
||||
newPanel.Children.Add(targetGroup);
|
||||
}
|
||||
else
|
||||
{
|
||||
newPanel.Children.Add(targetGroup);
|
||||
newPanel.Children.Add(newGroup);
|
||||
}
|
||||
|
||||
parentPanel.Children.Insert(index, newPanel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DockTarget CalculateDockTarget(DockGroup group, Windows.Foundation.Point position)
|
||||
{
|
||||
double w = group.ActualWidth;
|
||||
double h = group.ActualHeight;
|
||||
double x = position.X;
|
||||
double y = position.Y;
|
||||
|
||||
if (x < w * 0.25) return DockTarget.Left;
|
||||
if (x > w * 0.75) return DockTarget.Right;
|
||||
if (y < h * 0.25) return DockTarget.Top;
|
||||
if (y > h * 0.75) return DockTarget.Bottom;
|
||||
return DockTarget.Center;
|
||||
}
|
||||
|
||||
internal void CreateFloatingWindow(DockDocument doc)
|
||||
{
|
||||
// To be implemented in Task 6
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/DockGroup.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs
|
||||
git commit -m "feat(docking): implement drag and drop logic"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Floating Window
|
||||
|
||||
**Files:**
|
||||
- Create: `src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs`
|
||||
- Modify: `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs`
|
||||
|
||||
- [ ] **Step 1: Create FloatingWindow**
|
||||
Create `src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs`:
|
||||
```csharp
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace Ghost.Editor.View.Controls.Docking;
|
||||
|
||||
public class FloatingWindow : Window
|
||||
{
|
||||
public FloatingWindow(DockDocument document)
|
||||
{
|
||||
var layout = new DockingLayout();
|
||||
var group = new DockGroup();
|
||||
group.Children.Add(document);
|
||||
|
||||
var panel = new DockPanel();
|
||||
panel.Children.Add(group);
|
||||
layout.RootPanel = panel;
|
||||
|
||||
Content = layout;
|
||||
|
||||
// Basic window setup
|
||||
AppWindow.Resize(new Windows.Graphics.SizeInt32(800, 600));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update DockingLayout**
|
||||
Modify `src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs` to implement `CreateFloatingWindow`:
|
||||
```csharp
|
||||
internal void CreateFloatingWindow(DockDocument doc)
|
||||
{
|
||||
var window = new FloatingWindow(doc);
|
||||
window.Activate();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add src/Editor/Ghost.Editor/View/Controls/Docking/FloatingWindow.cs src/Editor/Ghost.Editor/View/Controls/Docking/DockingLayout.cs
|
||||
git commit -m "feat(docking): add floating window support"
|
||||
```
|
||||
@@ -1,75 +0,0 @@
|
||||
# DockLayout System Design
|
||||
|
||||
## Purpose
|
||||
To create a fully-featured docking layout system for the Ghost Engine Editor using WinUI 3, supporting tab tearing, window popping, and dynamic splitting of regions in a style similar to Unity or Blender.
|
||||
|
||||
## Architecture
|
||||
|
||||
The DockLayout will be entirely driven by a C# data model that represents a tree of nodes. The UI (`DockLayout` control) will observe this tree and recursively generate the corresponding XAML `Grid` and `TabView` elements.
|
||||
|
||||
### Core Data Model (The Node Tree)
|
||||
|
||||
```csharp
|
||||
public abstract class DockNode : INotifyPropertyChanged { }
|
||||
|
||||
// Represents a split region (Grid)
|
||||
public class DockGroupNode : DockNode
|
||||
{
|
||||
public Orientation Orientation { get; set; } // Horizontal or Vertical
|
||||
public ObservableCollection<DockNode> Children { get; }
|
||||
public ObservableCollection<GridLength> Sizes { get; } // Replaces Ratios for better WinUI 3 Grid binding
|
||||
}
|
||||
|
||||
// Represents a leaf node containing a TabView
|
||||
public class DockPanelNode : DockNode
|
||||
{
|
||||
// The items shown in the TabView
|
||||
public ObservableCollection<object> Items { get; }
|
||||
public int SelectedIndex { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### Visual Components
|
||||
|
||||
1. **`DockLayout` (Control)**
|
||||
* The root control.
|
||||
* Takes a `DockNode` (usually a `DockGroupNode`) as its `Root`.
|
||||
* Listens to Drag/Drop events to render the transparent drop target overlay over the layout.
|
||||
|
||||
2. **Node Renderers**
|
||||
* A recursive template selector or code-behind builder that converts `DockGroupNode` into a `Grid` with `GridSplitter`s.
|
||||
* Converts `DockPanelNode` into a `NavigationTabView` (or standard `TabView` with customized drag behaviors).
|
||||
|
||||
3. **`DockDropTarget` (Visual Overlay)**
|
||||
* A simple XAML structure (e.g., a colored `Border` with opacity) that highlights a portion of a `DockPanelNode` based on mouse position during a drag operation (Left/Right/Top/Bottom 25% for splitting, Center 50% for merging).
|
||||
|
||||
## Interactions & Data Flow
|
||||
|
||||
### 1. Internal Dragging (Within the same window)
|
||||
* User starts dragging a tab.
|
||||
* The `DockLayout` tracks the mouse pointer `DragOver` events.
|
||||
* It determines which `DockPanelNode` the mouse is currently hovering over.
|
||||
* It calculates relative coordinates to show the Unity-style drop highlight.
|
||||
* On **Drop**:
|
||||
* If dropped in the **center**: The tab object is moved from its source `DockPanelNode.Items` to the target `DockPanelNode.Items`.
|
||||
* If dropped on an **edge** (e.g., Right): The target `DockPanelNode` is removed from its parent `DockGroupNode`. A new `DockGroupNode` (Horizontal) is created to replace it. The target node and a *new* `DockPanelNode` (containing the dragged tab) are added as children to this new group.
|
||||
|
||||
### 2. Window Tear-Off (Full Docking)
|
||||
* User drags a tab completely outside the main window.
|
||||
* `TabView.TabDroppedOutside` is triggered.
|
||||
* The system creates a new WinUI 3 `Window`.
|
||||
* A new `DockLayout` instance is placed in this window.
|
||||
* The dragged tab object is removed from its original tree and added to a new `DockPanelNode` inside the new window's tree.
|
||||
* *Note: Because WinUI 3 supports multiple windows on the same UI thread, we don't have to worry about cross-thread marshaling of UI elements, making this much simpler than UWP.*
|
||||
|
||||
### 3. Empty Node Cleanup
|
||||
* When a `DockPanelNode`'s `Items` collection reaches 0 (the last tab is dragged away), it is removed from the tree.
|
||||
* If its parent `DockGroupNode` now only has 1 child remaining, that `DockGroupNode` is removed and replaced by its single child, collapsing the tree.
|
||||
|
||||
## Implementation Phases
|
||||
1. Define the Data Model (`DockNode` structure).
|
||||
2. Implement the recursive UI generation (binding the tree to nested Grids and TabViews).
|
||||
3. Implement basic tab moving (Merge) between existing `DockPanelNode`s.
|
||||
4. Implement edge dropping (Split) and the drop target highlight overlay.
|
||||
5. Implement empty node cleanup logic.
|
||||
6. Implement multi-window tear-off via `TabDroppedOutside`.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Docking Layout Design
|
||||
|
||||
## Overview
|
||||
A custom WinUI 3 docking layout system for GhostEngine's editor, inspired by `WinUI.Dock` but tailored to support Unity/Blender-style region highlighting and dynamic tab creation.
|
||||
|
||||
## Architecture & Components
|
||||
|
||||
The system uses a UI-driven approach where custom controls manage their own state and visual tree.
|
||||
|
||||
- **`DockingLayout`**: The root control. Manages the overall state, drag-and-drop coordination, and floating windows.
|
||||
- **`DockPanel`**: A container that splits its area horizontally or vertically (using a `Grid` with `GridSplitter`s).
|
||||
- **`DockGroup`**: A container that holds multiple documents, rendered as a WinUI 3 `TabView`.
|
||||
- **`DockDocument`**: The actual content item, representing a single tab and its payload.
|
||||
- **`FloatingWindow`**: A separate WinUI `Window` that hosts a minimal `DockingLayout` internally for torn-off tabs.
|
||||
- **`DockRegionHighlight`**: A visual overlay control (e.g., a semi-transparent blue rectangle) used during drag-and-drop to show exactly where the drop will occur (Left, Right, Top, Bottom, or Center).
|
||||
|
||||
## Drag and Drop Flow
|
||||
|
||||
1. **Start**: Dragging a `TabViewItem` initiates a drag operation. We store a reference to the dragged `DockDocument`.
|
||||
2. **Over**: As the pointer moves over a `DockGroup` or `DockPanel`, we calculate the relative pointer position to determine the target region (Left 25%, Right 25%, Top 25%, Bottom 25%, or Center 50%). We display the `DockRegionHighlight` over that specific area.
|
||||
3. **Drop**:
|
||||
- If dropped on an edge region (Left/Right/Top/Bottom), we split the target container: we create a new `DockPanel`, move the existing content to one side, and place the dragged document on the other.
|
||||
- If dropped in the Center of a `DockGroup`, we add the document as a new tab to that group.
|
||||
- If dropped outside the main window bounds, we create a new `FloatingWindow` and place the document inside it.
|
||||
|
||||
## Dynamic Creation API
|
||||
|
||||
The `DockingLayout` will expose methods to allow programmatically adding new panels or tabs at runtime (e.g., for a "plus icon" scenario).
|
||||
|
||||
```csharp
|
||||
public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null);
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
- The controls will be created under `src\Editor\Ghost.Editor\View\Controls\Docking\`.
|
||||
- We will use WinUI 3's built-in drag and drop APIs (`CanDrag`, `DragStarting`, `AllowDrop`, `DragOver`, `Drop`).
|
||||
- The `DockRegionHighlight` will be a `Border` or `Rectangle` added to the `DockingLayout`'s visual tree (e.g., in a `Popup` or an overlay `Grid`) and positioned absolutely based on the current drag target.
|
||||
Reference in New Issue
Block a user