diff --git a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs index ed0ebc8..909cbf3 100644 --- a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs +++ b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockGroupNode.cs @@ -4,19 +4,38 @@ using Microsoft.UI.Xaml.Controls; namespace Ghost.Editor.Core.Controls.Internal.Docking; +/// +/// A docking node that contains multiple children and arranges them in a specific orientation. +/// public partial class DockGroupNode : DockNode { + /// + /// Gets or sets the layout orientation of the children. + /// [ObservableProperty] public partial Orientation Orientation { get; set; } + /// + /// Gets the collection of child nodes. + /// + public ObservableCollection Children { get; } = new(); + + /// + /// Initializes a new instance of the class. + /// public DockGroupNode() { Orientation = Orientation.Horizontal; } - public ObservableCollection Children { get; } = new(); - + /// + /// Adds a child node to this group, enforcing tree invariants. + /// + /// The node to add. + /// Thrown if node is null. + /// Thrown if adding the node would create a cycle or if adding self. public void AddChild(DockNode node) + { ArgumentNullException.ThrowIfNull(node); @@ -50,6 +69,10 @@ public partial class DockGroupNode : DockNode Children.Add(node); } + /// + /// Removes a child node from this group. + /// + /// The node to remove. public void RemoveChild(DockNode node) { if (Children.Remove(node)) diff --git a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockNode.cs b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockNode.cs index 795e754..3b31ab0 100644 --- a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockNode.cs +++ b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockNode.cs @@ -2,8 +2,14 @@ using CommunityToolkit.Mvvm.ComponentModel; namespace Ghost.Editor.Core.Controls.Internal.Docking; +/// +/// Base class for all nodes in the docking layout tree. +/// public abstract partial class DockNode : ObservableObject { + /// + /// Gets the parent group of this node. + /// [ObservableProperty] - public partial DockGroupNode? Parent { get; set; } + public partial DockGroupNode? Parent { get; internal set; } } diff --git a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockPanelNode.cs b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockPanelNode.cs index f7906da..f699cc8 100644 --- a/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockPanelNode.cs +++ b/src/Editor/Ghost.Editor.Core/Controls/Internal/Docking/DockPanelNode.cs @@ -3,16 +3,31 @@ using CommunityToolkit.Mvvm.ComponentModel; namespace Ghost.Editor.Core.Controls.Internal.Docking; +/// +/// A docking node that contains a collection of items (tabs) and manages selection. +/// public partial class DockPanelNode : DockNode { + /// + /// Gets the collection of items (tabs) in this panel. + /// public ObservableCollection Items { get; } = new(); + /// + /// Gets or sets the index of the currently selected item. + /// [ObservableProperty] public partial int SelectedIndex { get; set; } + /// + /// Gets or sets the currently selected item. + /// [ObservableProperty] public partial object? SelectedItem { get; set; } + /// + /// Initializes a new instance of the class. + /// public DockPanelNode() { SelectedIndex = -1; @@ -21,18 +36,38 @@ public partial class DockPanelNode : DockNode private void OnItemsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { + // Reconcile selection on every collection change if (Items.Count == 0) { SelectedIndex = -1; SelectedItem = null; + return; } - else if (SelectedIndex >= Items.Count) + + if (SelectedItem != null) { - SelectedIndex = Items.Count - 1; + int index = Items.IndexOf(SelectedItem); + if (index != -1) + { + // Item still exists, update index if it changed + if (SelectedIndex != index) + { + SelectedIndex = index; + } + return; + } } - else if (SelectedIndex == -1 && Items.Count > 0) + + // SelectedItem is null or no longer in collection + if (SelectedIndex >= 0 && SelectedIndex < Items.Count) { - SelectedIndex = 0; + // Keep current index if valid, update item + SelectedItem = Items[SelectedIndex]; + } + else + { + // Fallback to first item or -1 + SelectedIndex = Items.Count > 0 ? 0 : -1; } } @@ -40,11 +75,18 @@ public partial class DockPanelNode : DockNode { if (value >= 0 && value < Items.Count) { - SelectedItem = Items[value]; + object newItem = Items[value]; + if (SelectedItem != newItem) + { + SelectedItem = newItem; + } } else if (value == -1) { - SelectedItem = null; + if (SelectedItem != null) + { + SelectedItem = null; + } } else { @@ -57,14 +99,26 @@ public partial class DockPanelNode : DockNode { if (value == null) { - SelectedIndex = -1; + if (SelectedIndex != -1) + { + SelectedIndex = -1; + } } else { int index = Items.IndexOf(value); if (index != -1) { - SelectedIndex = index; + if (SelectedIndex != index) + { + SelectedIndex = index; + } + } + else + { + // Item not in collection - reject selection + SelectedItem = null; + SelectedIndex = -1; } } } diff --git a/src/Test/Ghost.UnitTest/DockingModelTest.cs b/src/Test/Ghost.UnitTest/DockingModelTest.cs index 2bd2f04..b324619 100644 --- a/src/Test/Ghost.UnitTest/DockingModelTest.cs +++ b/src/Test/Ghost.UnitTest/DockingModelTest.cs @@ -16,7 +16,7 @@ public class DockingModelTest group.AddChild(child); Assert.AreEqual(group, child.Parent); - Assert.IsTrue(group.Children.Contains(child)); + CollectionAssert.Contains(group.Children, child); } [TestMethod] @@ -30,8 +30,8 @@ public class DockingModelTest group2.AddChild(child); Assert.AreEqual(group2, child.Parent); - Assert.IsFalse(group1.Children.Contains(child)); - Assert.IsTrue(group2.Children.Contains(child)); + CollectionAssert.DoesNotContain(group1.Children, child); + CollectionAssert.Contains(group2.Children, child); } [TestMethod] @@ -63,7 +63,7 @@ public class DockingModelTest group.RemoveChild(child); Assert.IsNull(child.Parent); - Assert.IsFalse(group.Children.Contains(child)); + CollectionAssert.DoesNotContain(group.Children, child); } [TestMethod] @@ -120,4 +120,59 @@ public class DockingModelTest Assert.AreEqual(-1, panel.SelectedIndex); Assert.IsNull(panel.SelectedItem); } + + [TestMethod] + public void TestPanel_RemoveMiddleItem_MaintainsSelection() + { + var panel = new DockPanelNode(); + var item1 = new object(); + var item2 = new object(); + var item3 = new object(); + + panel.Items.Add(item1); + panel.Items.Add(item2); + panel.Items.Add(item3); + + panel.SelectedItem = item2; + Assert.AreEqual(1, panel.SelectedIndex); + + // Remove item1 (before selection) + panel.Items.Remove(item1); + Assert.AreEqual(item2, panel.SelectedItem); + Assert.AreEqual(0, panel.SelectedIndex); + } + + [TestMethod] + public void TestPanel_RemoveSelectedItem_UpdatesSelection() + { + var panel = new DockPanelNode(); + var item1 = new object(); + var item2 = new object(); + + panel.Items.Add(item1); + panel.Items.Add(item2); + + panel.SelectedItem = item1; + panel.Items.Remove(item1); + + // Should fallback to next available item at same index + Assert.AreEqual(item2, panel.SelectedItem); + Assert.AreEqual(0, panel.SelectedIndex); + } + + [TestMethod] + public void TestPanel_SetInvalidSelectedItem_ResetsSelection() + { + var panel = new DockPanelNode(); + var item1 = new object(); + var item2 = new object(); + + panel.Items.Add(item1); + panel.SelectedItem = item1; + + panel.SelectedItem = item2; // Not in collection + + Assert.IsNull(panel.SelectedItem); + Assert.AreEqual(-1, panel.SelectedIndex); + } }