Refactor hierarchy drag-and-drop and cleanup inspector

Refactored HandleDrawer to use a static UpdateUI method for clarity. Removed unused PropertyDrawerContext. Added EntityCreated event to EditorWorldService. Updated Hierarchy.xaml to modern TreeView drag-and-drop events. Overhauled drag-and-drop logic in Hierarchy.xaml.cs for better validation, cycle prevention, and scene graph consistency. Introduced helper methods for parent retrieval and scene graph rebuilding. Improved error handling throughout drag-and-drop operations.
This commit is contained in:
2026-05-27 20:08:40 +09:00
parent 1fe080dd87
commit 2cbe1ca789
5 changed files with 105 additions and 82 deletions

View File

@@ -9,6 +9,13 @@ internal class HandleDrawer<T> : PropertyDrawer<Handle<T>> where T : unmanaged
{ {
public override FrameworkElement CreateControlT(PropertyNode<Handle<T>> model) public override FrameworkElement CreateControlT(PropertyNode<Handle<T>> model)
{ {
static void UpdateUI(HandlePropertyNode<T> handleNode, ReferenceField field)
{
var guid = handleNode?.AssetGuid ?? Guid.Empty;
field.HasValue = guid != Guid.Empty;
field.DisplayText = guid != Guid.Empty ? $"{typeof(T).Name} ({guid.ToString().Substring(0, 8)})" : $"None ({typeof(T).Name})";
}
var field = new ReferenceField var field = new ReferenceField
{ {
TypeLabel = typeof(T).Name, TypeLabel = typeof(T).Name,
@@ -16,13 +23,7 @@ internal class HandleDrawer<T> : PropertyDrawer<Handle<T>> where T : unmanaged
}; };
var handleNode = model as HandlePropertyNode<T>; var handleNode = model as HandlePropertyNode<T>;
Logger.DebugAssert(handleNode != null);
Action updateUI = () =>
{
var guid = handleNode?.AssetGuid ?? Guid.Empty;
field.HasValue = guid != Guid.Empty;
field.DisplayText = guid != Guid.Empty ? $"{typeof(T).Name} ({guid.ToString().Substring(0, 8)})" : $"None ({typeof(T).Name})";
};
field.ValidateDrop = (args) => field.ValidateDrop = (args) =>
{ {
@@ -32,12 +33,16 @@ internal class HandleDrawer<T> : PropertyDrawer<Handle<T>> where T : unmanaged
field.OnDropAccepted = async (args) => field.OnDropAccepted = async (args) =>
{ {
if (handleNode == null) return; if (handleNode == null)
{
return;
}
var text = await args.DataView.GetTextAsync(); var text = await args.DataView.GetTextAsync();
if (Guid.TryParse(text, out var guid)) if (Guid.TryParse(text, out var guid))
{ {
handleNode.SetHandleFromAsset(guid); handleNode.SetHandleFromAsset(guid);
updateUI(); UpdateUI(handleNode, field);
} }
}; };
@@ -46,11 +51,11 @@ internal class HandleDrawer<T> : PropertyDrawer<Handle<T>> where T : unmanaged
if (handleNode != null) if (handleNode != null)
{ {
handleNode.ClearHandle(); handleNode.ClearHandle();
updateUI(); UpdateUI(handleNode, field);
} }
}; };
updateUI(); UpdateUI(handleNode, field);
// When ECS value changes outside of UI // When ECS value changes outside of UI
model.OnValueChanged += (val) => model.OnValueChanged += (val) =>
@@ -58,7 +63,7 @@ internal class HandleDrawer<T> : PropertyDrawer<Handle<T>> where T : unmanaged
// UI Thread check usually required here, but property model events should be on UI thread or marshaled // UI Thread check usually required here, but property model events should be on UI thread or marshaled
field.DispatcherQueue.TryEnqueue(() => field.DispatcherQueue.TryEnqueue(() =>
{ {
updateUI(); UpdateUI(handleNode, field);
}); });
}; };

View File

@@ -1,12 +0,0 @@
using Ghost.Editor.Core.SceneGraph;
using Ghost.Entities;
namespace Ghost.Editor.Core.Inspector;
public sealed class PropertyDrawerContext
{
public required World World { get; init; }
public required Entity Entity { get; init; }
public required EntityNode EntityNode { get; init; }
public required ComponentNode ComponentNode { get; init; }
}

View File

@@ -20,6 +20,7 @@ public class EditorWorldService : IDisposable
{ {
get; get;
} = new(); } = new();
public event Action<Entity, string, ushort>? EntityCreated; public event Action<Entity, string, ushort>? EntityCreated;
public event Action<Entity>? EntityDestroyed; public event Action<Entity>? EntityDestroyed;
public event Action<Entity, Entity, Entity>? EntityParentChanged; // (child, oldParent, newParent) public event Action<Entity, Entity, Entity>? EntityParentChanged; // (child, oldParent, newParent)

View File

@@ -101,11 +101,11 @@
Grid.Row="1" Grid.Row="1"
Margin="4,2,0,2" Margin="4,2,0,2"
AllowDrop="True" AllowDrop="True"
CanDrag="True"
CanDragItems="True" CanDragItems="True"
CanReorderItems="True" CanReorderItems="True"
DragItemsCompleted="OnTreeViewDragItemsCompleted"
DragItemsStarting="OnTreeViewDragItemsStarting" DragItemsStarting="OnTreeViewDragItemsStarting"
DragOver="OnTreeViewDragOver"
Drop="OnTreeViewDrop"
ItemTemplateSelector="{StaticResource SceneGraphTemplateSelector}" ItemTemplateSelector="{StaticResource SceneGraphTemplateSelector}"
KeyDown="OnTreeViewKeyDown" KeyDown="OnTreeViewKeyDown"
SelectionMode="Single" /> SelectionMode="Single" />

View File

@@ -1,11 +1,12 @@
using Ghost.Editor.Core.Contracts; using Ghost.Editor.Core.Contracts;
using Ghost.Editor.Core.SceneGraph; using Ghost.Editor.Core.SceneGraph;
using Ghost.Editor.Core.Services; using Ghost.Editor.Core.Services;
using Ghost.Core;
using Ghost.Engine; using Ghost.Engine;
using Ghost.Entities;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
namespace Ghost.Editor.Views.Controls; namespace Ghost.Editor.Views.Controls;
@@ -72,80 +73,87 @@ public sealed partial class Hierarchy : UserControl
} }
} }
private void OnTreeViewDragOver(object sender, DragEventArgs e) private void OnTreeViewDragItemsCompleted(TreeView sender, TreeViewDragItemsCompletedEventArgs args)
{ {
e.AcceptedOperation = global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.None; var entityNode = args.Items.Count > 0 ? args.Items[0] as EntityNode : _draggedNode;
_draggedNode = null;
if (_draggedNode == null) if (entityNode == null)
{ {
return; return;
} }
var targetItem = GetAncestorTreeViewItem(e.OriginalSource as DependencyObject); if (args.DropResult != global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move)
if (targetItem == null)
{ {
RebuildSceneGraphFromECS();
return; return;
} }
var targetNode = targetItem.DataContext as SceneGraphNode; if (args.NewParentItem is not SceneGraphNode newParent)
if (targetNode == null)
{ {
RebuildSceneGraphFromECS();
return; return;
} }
// 1. Can't drag onto itself if (newParent == entityNode)
if (_draggedNode == targetNode)
{ {
RebuildSceneGraphFromECS();
return; return;
} }
// 2. Can't drag onto a child of itself (cycle checking) var result = Error.None;
if (targetNode is EntityNode targetEntityNode)
if (newParent is EntityNode parentEntityNode)
{ {
if (HierarchyUtility.IsAncestor(_worldService.EditorWorld, targetEntityNode.Entity, _draggedNode.Entity)) if (HierarchyUtility.IsAncestor(_worldService.EditorWorld, parentEntityNode.Entity, entityNode.Entity))
{ {
RebuildSceneGraphFromECS();
return; return;
} }
}
e.AcceptedOperation = global::Windows.ApplicationModel.DataTransfer.DataPackageOperation.Move; var currentParent = GetCurrentParent(entityNode);
} if (currentParent == parentEntityNode.Entity)
private void OnTreeViewDrop(object sender, DragEventArgs e)
{
if (_draggedNode == null)
{
return;
}
var targetItem = GetAncestorTreeViewItem(e.OriginalSource as DependencyObject);
if (targetItem == null)
{
return;
}
var targetNode = targetItem.DataContext as SceneGraphNode;
if (targetNode == null)
{
return;
}
if (_draggedNode == targetNode)
{
return;
}
if (targetNode is EntityNode targetEntityNode)
{
if (!HierarchyUtility.IsAncestor(_worldService.EditorWorld, targetEntityNode.Entity, _draggedNode.Entity))
{ {
_worldService.SetParent(_draggedNode.Entity, targetEntityNode.Entity); RebuildSceneGraphFromECS();
return;
}
result = _worldService.SetParent(entityNode.Entity, parentEntityNode.Entity);
}
else if (newParent is SceneNode sceneNode)
{
var currentParent = GetCurrentParent(entityNode);
var sceneChanged = _worldService.GetEntitySceneID(entityNode.Entity) != sceneNode.Scene.ID;
if (!currentParent.IsValid && !sceneChanged)
{
RebuildSceneGraphFromECS();
return;
}
if (currentParent.IsValid)
{
result = _worldService.RemoveParent(entityNode.Entity);
if (result != Error.None)
{
RebuildSceneGraphFromECS();
return;
}
}
if (sceneChanged)
{
_worldService.ChangeEntityScene(entityNode.Entity, sceneNode.Scene.ID);
} }
} }
else if (targetNode is SceneNode sceneNode) else
{ {
_worldService.RemoveParent(_draggedNode.Entity); RebuildSceneGraphFromECS();
_worldService.ChangeEntityScene(_draggedNode.Entity, sceneNode.Scene.ID); return;
}
if (result != Error.None)
{
RebuildSceneGraphFromECS();
} }
} }
@@ -177,17 +185,38 @@ public sealed partial class Hierarchy : UserControl
} }
} }
private TreeViewItem? GetAncestorTreeViewItem(DependencyObject? current) private Entity GetCurrentParent(EntityNode entityNode)
{ {
while (current != null) if (!_worldService.EditorWorld.EntityManager.HasComponent<Ghost.Engine.Components.Hierarchy>(entityNode.Entity))
{ {
if (current is TreeViewItem item) return Entity.Invalid;
{ }
return item;
} return _worldService.EditorWorld.EntityManager.GetComponent<Ghost.Engine.Components.Hierarchy>(entityNode.Entity).parent;
current = VisualTreeHelper.GetParent(current); }
private void RebuildSceneGraphFromECS()
{
var names = new Dictionary<Entity, string>();
foreach (var sceneNode in _worldService.RootNodes)
{
CaptureEntityNames(sceneNode, names);
}
_worldService.RebuildSceneGraph(names);
}
private static void CaptureEntityNames(SceneGraphNode node, Dictionary<Entity, string> names)
{
if (node is EntityNode entityNode)
{
names[entityNode.Entity] = entityNode.Name;
}
foreach (var child in node.Children)
{
CaptureEntityNames(child, names);
} }
return null;
} }
private void OnUnloaded(object sender, RoutedEventArgs e) private void OnUnloaded(object sender, RoutedEventArgs e)