docs: add dock layout implementation plan
This commit is contained in:
669
docs/superpowers/plans/2026-03-28-dock-layout.md
Normal file
669
docs/superpowers/plans/2026-03-28-dock-layout.md
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
# 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"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user