Files
GhostEngine/docs/superpowers/plans/2026-03-28-dock-layout.md

22 KiB

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

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

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

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

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

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

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

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:

        // ... inside CreateUIForNode for DockPanelNode ...
            tabView.TabDragStarting += TabView_TabDragStarting;
            tabView.TabDroppedOutside += TabView_TabDroppedOutside; // NEW
            
            return tabView;

Add event handler:

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