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:
2026-04-13 23:07:52 +09:00
parent c66fda5332
commit 817b32b8d9
69 changed files with 1436 additions and 2095 deletions

View 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 |

View File

@@ -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"
```

View File

@@ -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"
```

View File

@@ -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`.

View File

@@ -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.