From f38ad04c4f1b14c6cee8ac4eb2c2075f566e313a Mon Sep 17 00:00:00 2001 From: Misaki Date: Sat, 28 Mar 2026 12:14:51 +0900 Subject: [PATCH] docs: add dock layout implementation plan --- .../plans/2026-03-28-dock-layout.md | 669 ++++++++++++++++++ 1 file changed, 669 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-dock-layout.md diff --git a/docs/superpowers/plans/2026-03-28-dock-layout.md b/docs/superpowers/plans/2026-03-28-dock-layout.md new file mode 100644 index 0000000..f10dafa --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-dock-layout.md @@ -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 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 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 + + + + +``` + +- [ ] **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 + + + + + + +``` + +- [ ] **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 + + + + + + +``` + +`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" +```