fix(dock): extract mutation engine to core and improve test coverage

This commit is contained in:
2026-03-28 15:03:08 +09:00
parent b3d753fd08
commit 5efd0c8aee
5 changed files with 182 additions and 175 deletions

View File

@@ -3,7 +3,7 @@ namespace Ghost.Editor.Core.Controls.Internal.Docking;
/// <summary>
/// Defines the possible dock positions for a drop operation.
/// </summary>
internal enum DockPosition
public enum DockPosition
{
Center,
Top,
@@ -16,7 +16,7 @@ internal enum DockPosition
/// <summary>
/// Helper class for docking-related calculations.
/// </summary>
internal static class DockMath
public static class DockMath
{
/// <summary>
/// Calculates the dock position based on the relative position within a target element.

View File

@@ -0,0 +1,142 @@
using Ghost.Editor.Core.Controls.Internal.Docking;
using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.Core.Controls.Internal.Docking;
/// <summary>
/// Provides methods for mutating the docking layout tree.
/// </summary>
public static class DockMutationEngine
{
/// <summary>
/// Applies the tree mutation for a drop operation.
/// </summary>
/// <returns>True if the mutation was applied; otherwise, false.</returns>
public static bool TryApplyDropMutation(DockGroupNode root, DockPanelNode targetNode, DockPanelNode sourceNode, object item, DockPosition position)
{
if (position == DockPosition.Center)
{
if (!sourceNode.Items.Remove(item)) return false;
targetNode.Items.Add(item);
return true;
}
// Split scenario
bool isHorizontalSplit = position == DockPosition.Left || position == DockPosition.Right;
bool isAfter = position == DockPosition.Right || position == DockPosition.Bottom;
var parentGroup = targetNode.Parent;
if (parentGroup == null)
{
// Target is root or only child of root.
if (root.Children.Count == 1 && ReferenceEquals(root.Children[0], targetNode))
{
if (!sourceNode.Items.Remove(item)) return false;
var newGroup = new DockGroupNode
{
Orientation = isHorizontalSplit ? Orientation.Horizontal : Orientation.Vertical
};
var newNode = new DockPanelNode();
newNode.Items.Add(item);
root.RemoveChild(targetNode);
root.AddChild(newGroup);
if (isAfter)
{
newGroup.AddChild(targetNode);
newGroup.AddChild(newNode);
}
else
{
newGroup.AddChild(newNode);
newGroup.AddChild(targetNode);
}
return true;
}
return false;
}
int targetIndex = parentGroup.Children.IndexOf(targetNode);
if (targetIndex < 0) return false;
if (!sourceNode.Items.Remove(item)) return false;
if ((isHorizontalSplit && parentGroup.Orientation == Orientation.Horizontal) ||
(!isHorizontalSplit && parentGroup.Orientation == Orientation.Vertical))
{
var newNode = new DockPanelNode();
newNode.Items.Add(item);
parentGroup.InsertChild(isAfter ? targetIndex + 1 : targetIndex, newNode);
}
else
{
parentGroup.RemoveChild(targetNode);
var newGroup = new DockGroupNode
{
Orientation = isHorizontalSplit ? Orientation.Horizontal : Orientation.Vertical
};
var newNode = new DockPanelNode();
newNode.Items.Add(item);
if (isAfter)
{
newGroup.AddChild(targetNode);
newGroup.AddChild(newNode);
}
else
{
newGroup.AddChild(newNode);
newGroup.AddChild(targetNode);
}
parentGroup.InsertChild(targetIndex, newGroup);
}
return true;
}
/// <summary>
/// Cleans up empty panels and redundant groups in the tree.
/// </summary>
public static void CleanupEmptyNodes(DockNode node)
{
if (node is DockPanelNode panelNode && panelNode.Items.Count > 0) return;
var parentGroup = node.Parent;
if (parentGroup == null) return;
// If it's an empty panel, remove it
if (node is DockPanelNode emptyPanel && emptyPanel.Items.Count == 0)
{
parentGroup.RemoveChild(emptyPanel);
CleanupEmptyNodes(parentGroup);
return;
}
// If it's a group with 0 or 1 children, collapse it
if (node is DockGroupNode group)
{
if (group.Children.Count == 0)
{
parentGroup.RemoveChild(group);
CleanupEmptyNodes(parentGroup);
}
else if (group.Children.Count == 1)
{
var onlyChild = group.Children[0];
int index = parentGroup.Children.IndexOf(group);
if (index >= 0)
{
parentGroup.RemoveChild(group);
parentGroup.InsertChild(index, onlyChild);
CleanupEmptyNodes(parentGroup);
}
}
}
}
}

View File

@@ -401,145 +401,19 @@ public sealed partial class DockLayout : Control
return; // Reordering within same tab is handled natively by TabView
}
// 1. Execute mutation
if (TryApplyDropMutation(targetNode, _sourceNode, _draggedItem, _currentDropPosition))
if (Root == null)
{
CleanupEmptyNodes(_sourceNode);
}
ClearDragOperationState();
}
/// <summary>
/// Applies the tree mutation for a drop operation.
/// </summary>
/// <returns>True if the mutation was applied; otherwise, false.</returns>
internal bool TryApplyDropMutation(DockPanelNode targetNode, DockPanelNode sourceNode, object item, DockPosition position)
{
if (position == DockPosition.Center)
{
if (!sourceNode.Items.Remove(item)) return false;
targetNode.Items.Add(item);
return true;
}
// Split scenario
bool isHorizontalSplit = position == DockPosition.Left || position == DockPosition.Right;
bool isAfter = position == DockPosition.Right || position == DockPosition.Bottom;
var parentGroup = targetNode.Parent;
if (parentGroup == null)
{
// Target is root or only child of root.
// In a valid tree, a Panel must have a parent Group (Root is always a Group).
if (Root == null) return false;
if (Root.Children.Count == 1 && ReferenceEquals(Root.Children[0], targetNode))
{
if (!sourceNode.Items.Remove(item)) return false;
var newGroup = new DockGroupNode
{
Orientation = isHorizontalSplit ? Orientation.Horizontal : Orientation.Vertical
};
var newNode = new DockPanelNode();
newNode.Items.Add(item);
Root.RemoveChild(targetNode);
Root.AddChild(newGroup);
if (isAfter)
{
newGroup.AddChild(targetNode);
newGroup.AddChild(newNode);
}
else
{
newGroup.AddChild(newNode);
newGroup.AddChild(targetNode);
}
return true;
}
return false;
}
int targetIndex = parentGroup.Children.IndexOf(targetNode);
if (targetIndex < 0) return false;
if (!sourceNode.Items.Remove(item)) return false;
if ((isHorizontalSplit && parentGroup.Orientation == Orientation.Horizontal) ||
(!isHorizontalSplit && parentGroup.Orientation == Orientation.Vertical))
{
var newNode = new DockPanelNode();
newNode.Items.Add(item);
parentGroup.InsertChild(isAfter ? targetIndex + 1 : targetIndex, newNode);
}
else
{
parentGroup.RemoveChild(targetNode);
var newGroup = new DockGroupNode
{
Orientation = isHorizontalSplit ? Orientation.Horizontal : Orientation.Vertical
};
var newNode = new DockPanelNode();
newNode.Items.Add(item);
if (isAfter)
{
newGroup.AddChild(targetNode);
newGroup.AddChild(newNode);
}
else
{
newGroup.AddChild(newNode);
newGroup.AddChild(targetNode);
}
parentGroup.InsertChild(targetIndex, newGroup);
}
return true;
}
private void CleanupEmptyNodes(DockNode node)
{
if (node is DockPanelNode panelNode && panelNode.Items.Count > 0) return;
var parentGroup = node.Parent;
if (parentGroup == null) return;
// If it's an empty panel, remove it
if (node is DockPanelNode emptyPanel && emptyPanel.Items.Count == 0)
{
parentGroup.RemoveChild(emptyPanel);
CleanupEmptyNodes(parentGroup);
ClearDragOperationState();
return;
}
// If it's a group with 0 or 1 children, collapse it
if (node is DockGroupNode group)
// 1. Execute mutation
if (DockMutationEngine.TryApplyDropMutation(Root, targetNode, _sourceNode, _draggedItem, _currentDropPosition))
{
if (group.Children.Count == 0)
{
parentGroup.RemoveChild(group);
CleanupEmptyNodes(parentGroup);
}
else if (group.Children.Count == 1)
{
var onlyChild = group.Children[0];
int index = parentGroup.Children.IndexOf(group);
if (index >= 0)
{
parentGroup.RemoveChild(group);
parentGroup.InsertChild(index, onlyChild);
CleanupEmptyNodes(parentGroup);
}
}
DockMutationEngine.CleanupEmptyNodes(_sourceNode);
}
ClearDragOperationState();
}

View File

@@ -205,6 +205,24 @@ public class DockingModelTest
Assert.AreEqual(child2, group.Children[1]);
}
[TestMethod]
public void TestInsertChild_ExistingChild_AtCount_Clamps()
{
var group = new DockGroupNode();
var child1 = new DockPanelNode();
var child2 = new DockPanelNode();
group.AddChild(child1);
group.AddChild(child2);
// Move child1 to index 2 (Count). Should clamp to 1.
group.InsertChild(2, child1);
Assert.HasCount(2, group.Children);
Assert.AreEqual(child2, group.Children[0]);
Assert.AreEqual(child1, group.Children[1]);
}
[TestMethod]
public void TestPanel_SetInvalidSelectedItem_ResetsSelection()
{

View File

@@ -18,11 +18,9 @@ public class DockingMutationTest
root.AddChild(panel1);
root.AddChild(panel2);
// Simulate Center Drop: panel1 -> panel2
bool removed = panel1.Items.Remove(item);
Assert.IsTrue(removed);
panel2.Items.Add(item);
bool result = DockMutationEngine.TryApplyDropMutation(root, panel2, panel1, item, DockPosition.Center);
Assert.IsTrue(result);
Assert.IsEmpty(panel1.Items);
Assert.HasCount(1, panel2.Items);
Assert.AreEqual(item, panel2.Items[0]);
@@ -38,26 +36,19 @@ public class DockingMutationTest
panel1.Items.Add(item);
root.AddChild(panel1);
// Simulate Vertical Split Drop (Bottom) on panel1
// 1. Remove from source
bool removed = panel1.Items.Remove(item);
Assert.IsTrue(removed);
// 2. Different orientation (Vertical), replace panel1 with new group
root.RemoveChild(panel1);
var newGroup = new DockGroupNode { Orientation = Microsoft.UI.Xaml.Controls.Orientation.Vertical };
var newNode = new DockPanelNode();
newNode.Items.Add(item);
newGroup.AddChild(panel1); // Original
newGroup.AddChild(newNode); // New split
root.AddChild(newGroup);
// Vertical Split Drop (Bottom) on panel1
bool result = DockMutationEngine.TryApplyDropMutation(root, panel1, panel1, item, DockPosition.Bottom);
Assert.IsTrue(result);
Assert.HasCount(1, root.Children);
Assert.AreEqual(newGroup, root.Children[0]);
var newGroup = root.Children[0] as DockGroupNode;
Assert.IsNotNull(newGroup);
Assert.AreEqual(Microsoft.UI.Xaml.Controls.Orientation.Vertical, newGroup.Orientation);
Assert.HasCount(2, newGroup.Children);
Assert.AreEqual(panel1, newGroup.Children[0]);
Assert.AreEqual(newNode, newGroup.Children[1]);
var newNode = newGroup.Children[1] as DockPanelNode;
Assert.IsNotNull(newNode);
Assert.HasCount(1, newNode.Items);
Assert.AreEqual(item, newNode.Items[0]);
}
@@ -74,15 +65,7 @@ public class DockingMutationTest
// panel1 becomes empty
panel1.Items.Clear();
// Simulate CleanupEmptyNodes(panel1)
// 1. panel1 is empty, remove from group1
group1.RemoveChild(panel1);
// 2. group1 is now empty, remove from root
if (group1.Children.Count == 0)
{
root.RemoveChild(group1);
}
DockMutationEngine.CleanupEmptyNodes(panel1);
Assert.IsEmpty(root.Children);
Assert.IsNull(group1.Parent);
@@ -104,17 +87,7 @@ public class DockingMutationTest
// panel2 is removed
group1.RemoveChild(panel2);
// group1 now has only 1 child (panel1), should collapse
if (group1.Children.Count == 1)
{
var onlyChild = group1.Children[0];
var parent = group1.Parent;
Assert.IsNotNull(parent);
int index = parent.Children.IndexOf(group1);
parent.RemoveChild(group1);
parent.InsertChild(index, onlyChild);
}
DockMutationEngine.CleanupEmptyNodes(group1);
Assert.HasCount(1, root.Children);
Assert.AreEqual(panel1, root.Children[0]);