fix(dock): robust selection sync, internal parent setter, and XML docs
This commit is contained in:
@@ -4,19 +4,38 @@ using Microsoft.UI.Xaml.Controls;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A docking node that contains multiple children and arranges them in a specific orientation.
|
||||||
|
/// </summary>
|
||||||
public partial class DockGroupNode : DockNode
|
public partial class DockGroupNode : DockNode
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the layout orientation of the children.
|
||||||
|
/// </summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial Orientation Orientation { get; set; }
|
public partial Orientation Orientation { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the collection of child nodes.
|
||||||
|
/// </summary>
|
||||||
|
public ObservableCollection<DockNode> Children { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DockGroupNode"/> class.
|
||||||
|
/// </summary>
|
||||||
public DockGroupNode()
|
public DockGroupNode()
|
||||||
{
|
{
|
||||||
Orientation = Orientation.Horizontal;
|
Orientation = Orientation.Horizontal;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ObservableCollection<DockNode> Children { get; } = new();
|
/// <summary>
|
||||||
|
/// Adds a child node to this group, enforcing tree invariants.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="node">The node to add.</param>
|
||||||
|
/// <exception cref="ArgumentNullException">Thrown if node is null.</exception>
|
||||||
|
/// <exception cref="InvalidOperationException">Thrown if adding the node would create a cycle or if adding self.</exception>
|
||||||
public void AddChild(DockNode node)
|
public void AddChild(DockNode node)
|
||||||
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(node);
|
ArgumentNullException.ThrowIfNull(node);
|
||||||
|
|
||||||
@@ -50,6 +69,10 @@ public partial class DockGroupNode : DockNode
|
|||||||
Children.Add(node);
|
Children.Add(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes a child node from this group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="node">The node to remove.</param>
|
||||||
public void RemoveChild(DockNode node)
|
public void RemoveChild(DockNode node)
|
||||||
{
|
{
|
||||||
if (Children.Remove(node))
|
if (Children.Remove(node))
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base class for all nodes in the docking layout tree.
|
||||||
|
/// </summary>
|
||||||
public abstract partial class DockNode : ObservableObject
|
public abstract partial class DockNode : ObservableObject
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the parent group of this node.
|
||||||
|
/// </summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial DockGroupNode? Parent { get; set; }
|
public partial DockGroupNode? Parent { get; internal set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,31 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
|
|
||||||
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
namespace Ghost.Editor.Core.Controls.Internal.Docking;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A docking node that contains a collection of items (tabs) and manages selection.
|
||||||
|
/// </summary>
|
||||||
public partial class DockPanelNode : DockNode
|
public partial class DockPanelNode : DockNode
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the collection of items (tabs) in this panel.
|
||||||
|
/// </summary>
|
||||||
public ObservableCollection<object> Items { get; } = new();
|
public ObservableCollection<object> Items { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the index of the currently selected item.
|
||||||
|
/// </summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial int SelectedIndex { get; set; }
|
public partial int SelectedIndex { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the currently selected item.
|
||||||
|
/// </summary>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
public partial object? SelectedItem { get; set; }
|
public partial object? SelectedItem { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="DockPanelNode"/> class.
|
||||||
|
/// </summary>
|
||||||
public DockPanelNode()
|
public DockPanelNode()
|
||||||
{
|
{
|
||||||
SelectedIndex = -1;
|
SelectedIndex = -1;
|
||||||
@@ -21,18 +36,38 @@ public partial class DockPanelNode : DockNode
|
|||||||
|
|
||||||
private void OnItemsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
private void OnItemsCollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
|
// Reconcile selection on every collection change
|
||||||
if (Items.Count == 0)
|
if (Items.Count == 0)
|
||||||
{
|
{
|
||||||
SelectedIndex = -1;
|
SelectedIndex = -1;
|
||||||
SelectedItem = null;
|
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)
|
if (value >= 0 && value < Items.Count)
|
||||||
{
|
{
|
||||||
SelectedItem = Items[value];
|
object newItem = Items[value];
|
||||||
|
if (SelectedItem != newItem)
|
||||||
|
{
|
||||||
|
SelectedItem = newItem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else if (value == -1)
|
else if (value == -1)
|
||||||
{
|
{
|
||||||
SelectedItem = null;
|
if (SelectedItem != null)
|
||||||
|
{
|
||||||
|
SelectedItem = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -57,14 +99,26 @@ public partial class DockPanelNode : DockNode
|
|||||||
{
|
{
|
||||||
if (value == null)
|
if (value == null)
|
||||||
{
|
{
|
||||||
SelectedIndex = -1;
|
if (SelectedIndex != -1)
|
||||||
|
{
|
||||||
|
SelectedIndex = -1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
int index = Items.IndexOf(value);
|
int index = Items.IndexOf(value);
|
||||||
if (index != -1)
|
if (index != -1)
|
||||||
{
|
{
|
||||||
SelectedIndex = index;
|
if (SelectedIndex != index)
|
||||||
|
{
|
||||||
|
SelectedIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Item not in collection - reject selection
|
||||||
|
SelectedItem = null;
|
||||||
|
SelectedIndex = -1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public class DockingModelTest
|
|||||||
group.AddChild(child);
|
group.AddChild(child);
|
||||||
|
|
||||||
Assert.AreEqual(group, child.Parent);
|
Assert.AreEqual(group, child.Parent);
|
||||||
Assert.IsTrue(group.Children.Contains(child));
|
CollectionAssert.Contains(group.Children, child);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
@@ -30,8 +30,8 @@ public class DockingModelTest
|
|||||||
group2.AddChild(child);
|
group2.AddChild(child);
|
||||||
|
|
||||||
Assert.AreEqual(group2, child.Parent);
|
Assert.AreEqual(group2, child.Parent);
|
||||||
Assert.IsFalse(group1.Children.Contains(child));
|
CollectionAssert.DoesNotContain(group1.Children, child);
|
||||||
Assert.IsTrue(group2.Children.Contains(child));
|
CollectionAssert.Contains(group2.Children, child);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
@@ -63,7 +63,7 @@ public class DockingModelTest
|
|||||||
group.RemoveChild(child);
|
group.RemoveChild(child);
|
||||||
|
|
||||||
Assert.IsNull(child.Parent);
|
Assert.IsNull(child.Parent);
|
||||||
Assert.IsFalse(group.Children.Contains(child));
|
CollectionAssert.DoesNotContain(group.Children, child);
|
||||||
}
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
@@ -120,4 +120,59 @@ public class DockingModelTest
|
|||||||
Assert.AreEqual(-1, panel.SelectedIndex);
|
Assert.AreEqual(-1, panel.SelectedIndex);
|
||||||
Assert.IsNull(panel.SelectedItem);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user