fix(docking): improve type safety, document retention, and container cleanup

This commit is contained in:
2026-03-28 22:29:14 +09:00
parent 45d810e01c
commit 55eb240de6
5 changed files with 178 additions and 44 deletions

View File

@@ -3,6 +3,9 @@ using System.Collections.ObjectModel;
namespace Ghost.Editor.View.Controls.Docking; namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// Base class for containers that can hold other dock modules.
/// </summary>
public abstract class DockContainer : DockModule public abstract class DockContainer : DockModule
{ {
private readonly ObservableCollection<DockModule> _children = new(); private readonly ObservableCollection<DockModule> _children = new();
@@ -20,6 +23,11 @@ public abstract class DockContainer : DockModule
} }
public virtual void AddChild(DockModule module) public virtual void AddChild(DockModule module)
{
InsertChild(_children.Count, module);
}
public virtual void InsertChild(int index, DockModule module)
{ {
ArgumentNullException.ThrowIfNull(module); ArgumentNullException.ThrowIfNull(module);
@@ -42,7 +50,8 @@ public abstract class DockContainer : DockModule
module.Owner?.RemoveChild(module); module.Owner?.RemoveChild(module);
module.Owner = this; module.Owner = this;
_children.Add(module); module.Root = Root;
_children.Insert(index, module);
} }
public virtual void RemoveChild(DockModule module) public virtual void RemoveChild(DockModule module)
@@ -52,6 +61,16 @@ public abstract class DockContainer : DockModule
if (_children.Remove(module)) if (_children.Remove(module))
{ {
module.Owner = null; module.Owner = null;
module.Root = null;
CheckCleanup();
}
}
protected virtual void CheckCleanup()
{
if (_children.Count == 0)
{
Owner?.RemoveChild(this);
} }
} }
@@ -60,9 +79,18 @@ public abstract class DockContainer : DockModule
foreach (var child in _children) foreach (var child in _children)
{ {
child.Owner = null; child.Owner = null;
child.Root = null;
} }
_children.Clear(); _children.Clear();
} }
protected override void OnRootChanged()
{
foreach (var child in _children)
{
child.Root = Root;
}
}
protected virtual void OnChildrenUpdated() { } protected virtual void OnChildrenUpdated() { }
} }

View File

@@ -4,6 +4,9 @@ using Microsoft.UI.Xaml.Data;
namespace Ghost.Editor.View.Controls.Docking; namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// A container that displays its children (documents) as tabs.
/// </summary>
[TemplatePart(Name = PART_TAB_VIEW, Type = typeof(TabView))] [TemplatePart(Name = PART_TAB_VIEW, Type = typeof(TabView))]
public partial class DockGroup : DockContainer public partial class DockGroup : DockContainer
{ {
@@ -27,9 +30,31 @@ public partial class DockGroup : DockContainer
base.AddChild(module); base.AddChild(module);
} }
public override void InsertChild(int index, DockModule module)
{
ArgumentNullException.ThrowIfNull(module);
if (module is not DockDocument)
{
throw new ArgumentException($"{nameof(DockGroup)} only accepts {nameof(DockDocument)} children.", nameof(module));
}
base.InsertChild(index, module);
}
protected override void OnApplyTemplate() protected override void OnApplyTemplate()
{ {
base.OnApplyTemplate(); base.OnApplyTemplate();
if (_tabView != null)
{
_tabView.TabDragStarting -= OnTabDragStarting;
_tabView.TabDroppedOutside -= OnTabDroppedOutside;
_tabView.DragOver -= OnDragOver;
_tabView.Drop -= OnDrop;
_tabView.DragLeave -= OnDragLeave;
}
_tabView = GetTemplateChild(PART_TAB_VIEW) as TabView; _tabView = GetTemplateChild(PART_TAB_VIEW) as TabView;
if (_tabView != null) if (_tabView != null)
@@ -49,7 +74,6 @@ public partial class DockGroup : DockContainer
if (args.Tab.Tag is DockDocument doc) if (args.Tab.Tag is DockDocument doc)
{ {
args.Data.Properties.Add("DockDocument", doc); args.Data.Properties.Add("DockDocument", doc);
doc.Detach();
} }
} }
@@ -65,7 +89,7 @@ public partial class DockGroup : DockContainer
{ {
if (e.DataView.Properties.ContainsKey("DockDocument")) if (e.DataView.Properties.ContainsKey("DockDocument"))
{ {
e.AcceptedOperation = Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move; e.AcceptedOperation = global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move;
Root?.ShowHighlight(this, e.GetPosition(this)); Root?.ShowHighlight(this, e.GetPosition(this));
} }
} }

View File

@@ -2,14 +2,32 @@ using Microsoft.UI.Xaml.Controls;
namespace Ghost.Editor.View.Controls.Docking; namespace Ghost.Editor.View.Controls.Docking;
/// <summary>
/// Base class for all dockable modules in the docking system.
/// </summary>
public abstract class DockModule : Control public abstract class DockModule : Control
{ {
public DockContainer? Owner { get; internal set; } public DockContainer? Owner { get; internal set; }
private DockingLayout? _root;
/// <summary> /// <summary>
/// Gets or sets the root docking layout this module belongs to. /// Gets or sets the root docking layout this module belongs to.
/// </summary> /// </summary>
public DockingLayout? Root { get; internal set; } public virtual DockingLayout? Root
{
get => _root;
internal set
{
if (_root != value)
{
_root = value;
OnRootChanged();
}
}
}
protected virtual void OnRootChanged() { }
public void Detach() public void Detach()
{ {

View File

@@ -41,6 +41,27 @@ public class DockPanel : DockContainer
UpdateLayoutStructure(); UpdateLayoutStructure();
} }
protected override void CheckCleanup()
{
if (Children.Count == 0)
{
base.CheckCleanup();
}
else if (Children.Count == 1)
{
var child = Children[0];
var owner = Owner;
if (owner != null)
{
int index = owner.Children.IndexOf(this);
owner.RemoveChild(this);
child.Detach();
owner.InsertChild(index, child);
}
}
}
private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) private static void OnOrientationChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{ {
((DockPanel)d).UpdateLayoutStructure(); ((DockPanel)d).UpdateLayoutStructure();
@@ -68,7 +89,7 @@ public class DockPanel : DockContainer
if (i < Children.Count - 1) if (i < Children.Count - 1)
{ {
_grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); _grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Columns, Width = SPLITTER_THICKNESS }; var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter { ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Columns, Width = SPLITTER_THICKNESS };
Grid.SetColumn(splitter, i * 2 + 1); Grid.SetColumn(splitter, i * 2 + 1);
_grid.Children.Add(splitter); _grid.Children.Add(splitter);
} }
@@ -86,7 +107,7 @@ public class DockPanel : DockContainer
if (i < Children.Count - 1) if (i < Children.Count - 1)
{ {
_grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); _grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
var splitter = new GridSplitter { ResizeDirection = GridSplitter.GridResizeDirection.Rows, Height = SPLITTER_THICKNESS }; var splitter = new CommunityToolkit.WinUI.Controls.GridSplitter { ResizeDirection = CommunityToolkit.WinUI.Controls.GridSplitter.GridResizeDirection.Rows, Height = SPLITTER_THICKNESS };
Grid.SetRow(splitter, i * 2 + 1); Grid.SetRow(splitter, i * 2 + 1);
_grid.Children.Add(splitter); _grid.Children.Add(splitter);
} }

View File

@@ -28,9 +28,7 @@ public class DockingLayout : Control
set => SetValue(RootPanelProperty, value); set => SetValue(RootPanelProperty, value);
} }
// Used in Task 5 for drag and drop highlight
private Canvas? _overlayCanvas; private Canvas? _overlayCanvas;
// Used in Task 5 for drag and drop highlight
private DockRegionHighlight? _highlight; private DockRegionHighlight? _highlight;
public DockingLayout() public DockingLayout()
@@ -74,11 +72,6 @@ public class DockingLayout : Control
/// <param name="targetGroup">The target group to add the document to. If null, a suitable group will be found or created.</param> /// <param name="targetGroup">The target group to add the document to. If null, a suitable group will be found or created.</param>
public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null) public void AddDocument(DockDocument document, DockTarget target, DockGroup? targetGroup = null)
{ {
if (target != DockTarget.Center)
{
throw new NotImplementedException("Target docking will be implemented in Task 5");
}
if (targetGroup != null && targetGroup.Root != this) if (targetGroup != null && targetGroup.Root != this)
{ {
throw new ArgumentException("targetGroup does not belong to this DockingLayout"); throw new ArgumentException("targetGroup does not belong to this DockingLayout");
@@ -100,7 +93,77 @@ public class DockingLayout : Control
} }
} }
targetGroup.AddChild(document); if (target == DockTarget.Center)
{
targetGroup.AddChild(document);
}
else
{
SplitGroup(targetGroup, document, target);
}
}
private void SplitGroup(DockGroup targetGroup, DockDocument doc, DockTarget target)
{
var parentPanel = targetGroup.Owner as DockPanel;
var newGroup = new DockGroup();
newGroup.AddChild(doc);
var orientation = (target == DockTarget.Left || target == DockTarget.Right) ? Orientation.Horizontal : Orientation.Vertical;
if (parentPanel == null)
{
// targetGroup is the root
var newPanel = new DockPanel { Orientation = orientation };
RootPanel = newPanel;
if (target == DockTarget.Left || target == DockTarget.Top)
{
newPanel.AddChild(newGroup);
newPanel.AddChild(targetGroup);
}
else
{
newPanel.AddChild(targetGroup);
newPanel.AddChild(newGroup);
}
}
else
{
int index = parentPanel.Children.IndexOf(targetGroup);
if (parentPanel.Orientation == orientation)
{
// Same orientation, just insert
if (target == DockTarget.Left || target == DockTarget.Top)
{
parentPanel.InsertChild(index, newGroup);
}
else
{
parentPanel.InsertChild(index + 1, newGroup);
}
}
else
{
// Different orientation, need a new sub-panel
targetGroup.Detach();
var newPanel = new DockPanel { Orientation = orientation };
if (target == DockTarget.Left || target == DockTarget.Top)
{
newPanel.AddChild(newGroup);
newPanel.AddChild(targetGroup);
}
else
{
newPanel.AddChild(targetGroup);
newPanel.AddChild(newGroup);
}
parentPanel.InsertChild(index, newPanel);
}
}
} }
private static DockGroup? FindFirstDockGroup(DockContainer container) private static DockGroup? FindFirstDockGroup(DockContainer container)
@@ -125,7 +188,7 @@ public class DockingLayout : Control
return null; return null;
} }
internal void ShowHighlight(DockGroup targetGroup, Windows.Foundation.Point position) internal void ShowHighlight(DockGroup targetGroup, global::Windows.Foundation.Point position)
{ {
if (_highlight == null || _overlayCanvas == null) return; if (_highlight == null || _overlayCanvas == null) return;
@@ -147,10 +210,10 @@ public class DockingLayout : Control
} }
var transform = targetGroup.TransformToVisual(_overlayCanvas); var transform = targetGroup.TransformToVisual(_overlayCanvas);
var point = transform.TransformPoint(new Windows.Foundation.Point(x, y)); var point = transform.TransformPoint(new global::Windows.Foundation.Point(x, y));
Canvas.SetLeft(_highlight, point.X); Microsoft.UI.Xaml.Controls.Canvas.SetLeft(_highlight, point.X);
Canvas.SetTop(_highlight, point.Y); Microsoft.UI.Xaml.Controls.Canvas.SetTop(_highlight, point.Y);
_highlight.Width = width; _highlight.Width = width;
_highlight.Height = height; _highlight.Height = height;
} }
@@ -160,45 +223,24 @@ public class DockingLayout : Control
if (_highlight != null) _highlight.Visibility = Visibility.Collapsed; if (_highlight != null) _highlight.Visibility = Visibility.Collapsed;
} }
internal void HandleDrop(DockDocument doc, DockGroup targetGroup, Windows.Foundation.Point position) internal void HandleDrop(DockDocument doc, DockGroup targetGroup, global::Windows.Foundation.Point position)
{ {
HideHighlight(); HideHighlight();
var target = CalculateDockTarget(targetGroup, position); var target = CalculateDockTarget(targetGroup, position);
doc.Detach();
if (target == DockTarget.Center) if (target == DockTarget.Center)
{ {
targetGroup.AddChild(doc); targetGroup.AddChild(doc);
} }
else else
{ {
// Split logic: create new DockPanel, move targetGroup and doc into it SplitGroup(targetGroup, doc, target);
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.AddChild(doc);
if (target == DockTarget.Left || target == DockTarget.Top)
{
newPanel.AddChild(newGroup);
newPanel.AddChild(targetGroup);
}
else
{
newPanel.AddChild(targetGroup);
newPanel.AddChild(newGroup);
}
parentPanel.Children.Insert(index, newPanel);
}
} }
} }
private DockTarget CalculateDockTarget(DockGroup group, Windows.Foundation.Point position) private DockTarget CalculateDockTarget(DockGroup group, global::Windows.Foundation.Point position)
{ {
double w = group.ActualWidth; double w = group.ActualWidth;
double h = group.ActualHeight; double h = group.ActualHeight;
@@ -214,6 +256,7 @@ public class DockingLayout : Control
internal void CreateFloatingWindow(DockDocument doc) internal void CreateFloatingWindow(DockDocument doc)
{ {
doc.Detach();
// To be implemented in Task 6 // To be implemented in Task 6
} }
} }