Refine editor action shell and add regression tests

This commit is contained in:
2026-03-27 12:06:24 +08:00
parent c33404767e
commit 4b9a63098e
21 changed files with 838 additions and 308 deletions

View File

@@ -109,6 +109,8 @@
- `Inspector` 的 component section header 菜单已开始改成 callback/router 驱动,而不是在 widget 层硬编码动作 - `Inspector` 的 component section header 菜单已开始改成 callback/router 驱动,而不是在 widget 层硬编码动作
- `MenuBar` 的 File / View / Help / global shortcut 也开始继续下沉到 shared main-menu router - `MenuBar` 的 File / View / Help / global shortcut 也开始继续下沉到 shared main-menu router
- `Hierarchy / Project` 的 drag-drop payload、拖拽接收与部分选择语义也开始继续下沉到 shared action router - `Hierarchy / Project` 的 drag-drop payload、拖拽接收与部分选择语义也开始继续下沉到 shared action router
- `Hierarchy / Project / Inspector / Console` 的空白点击、popup 请求、日志绘制与 interactive-change 边界也开始继续从 panel 下沉到 shared router/state
- `MainMenu / Edit` 的菜单绘制、shortcut 触发与动作执行路径也开始继续合并成统一 router 入口
### 5. Dock / Layout 层 ### 5. Dock / Layout 层
@@ -158,6 +160,7 @@
- DX12 swapchain / render target / present / resize 已抽成 `Platform/D3D12WindowRenderer.h` - DX12 swapchain / render target / present / resize 已抽成 `Platform/D3D12WindowRenderer.h`
- scene title 拼装已抽成 `Core/EditorWindowTitle.h` - scene title 拼装已抽成 `Core/EditorWindowTitle.h`
- crash filter / stderr redirect / logging sink 初始化已继续从 `Application.cpp` 抽离 - crash filter / stderr redirect / logging sink 初始化已继续从 `Application.cpp` 抽离
- editor context 初始化、ImGui backend/session 初始化、layer attach/detach 与 frame render 壳方法也已继续从 `Application::Initialize/Shutdown/Render` 主流程中拆出
## 主要面板状态 ## 主要面板状态
@@ -176,6 +179,7 @@
- `Edit` 菜单与上下文快捷键开始共享同一套 edit action router - `Edit` 菜单与上下文快捷键开始共享同一套 edit action router
- `File / View / Help / global shortcut` 已开始继续从 panel 下沉到 shared main-menu router - `File / View / Help / global shortcut` 已开始继续从 panel 下沉到 shared main-menu router
- scene status 与 about dialog 已开始继续从 panel 下沉到共享 UI widget/dialog - scene status 与 about dialog 已开始继续从 panel 下沉到共享 UI widget/dialog
- `MenuBar` 已进一步退化为 router 驱动的薄壳File/View/Help/Edit 的拼装继续从 panel 移出
仍待完成: 仍待完成:
@@ -192,6 +196,9 @@
- 重命名交互已从 panel 局部字段收口到 shared inline edit state - 重命名交互已从 panel 局部字段收口到 shared inline edit state
- `Rename` 请求已能从 `MenuBar -> EventBus -> Hierarchy inline edit` 触发 - `Rename` 请求已能从 `MenuBar -> EventBus -> Hierarchy inline edit` 触发
- entity drag payload / 目标接收 / root drop / selection click 语义已开始继续从 panel 下沉到 shared hierarchy router - entity drag payload / 目标接收 / root drop / selection click 语义已开始继续从 panel 下沉到 shared hierarchy router
- hierarchy 空白点击清空选择与排序 popup 请求/菜单也已开始继续从 panel 下沉
- hierarchy 背景 / entity context popup 与 root drop target 入口也已开始继续从 panel 下沉
- rename commit 也已开始继续从 panel 直连 command 改成 shared hierarchy router 入口
仍待完成: 仍待完成:
@@ -208,6 +215,8 @@
- 创建文件夹弹窗已改成 shared popup state 驱动 - 创建文件夹弹窗已改成 shared popup state 驱动
- `Back / Open / Delete` 已接 panel-focused keyboard action - `Back / Open / Delete` 已接 panel-focused keyboard action
- asset drag payload / folder drop / 拖拽预览高亮已开始继续从 panel 下沉到 shared project router - asset drag payload / folder drop / 拖拽预览高亮已开始继续从 panel 下沉到 shared project router
- item 选择 / item context 请求 / 空白区 context popup 请求也已开始继续从 panel 下沉
- asset open 请求也已开始继续从 panel 直连 command 改成 shared project router 入口
仍待完成: 仍待完成:
@@ -225,6 +234,7 @@
- Add Component popup 已接 shared popup state - Add Component popup 已接 shared popup state
- Add Component popup 菜单项组装已开始从 panel 下沉到 shared inspector action router - Add Component popup 菜单项组装已开始从 panel 下沉到 shared inspector action router
- 组件 section header 的移除动作已开始从 widget 层硬编码迁回 inspector action router - 组件 section header 的移除动作已开始从 widget 层硬编码迁回 inspector action router
- selection 切换时的 interactive undo finalize、Add Component 按钮与 popup 绘制也已开始继续从 panel 下沉
仍待完成: 仍待完成:
@@ -240,6 +250,7 @@
- `Clear / Filter` 已接 action 层 - `Clear / Filter` 已接 action 层
- console filter 状态已从 panel 裸布尔字段收成独立 state object - console filter 状态已从 panel 裸布尔字段收成独立 state object
- console toolbar action 与日志文本格式化已继续从 panel 下沉到共享层 - console toolbar action 与日志文本格式化已继续从 panel 下沉到共享层
- log row 列表绘制与复制语义也已开始继续从 panel 下沉到 console router
仍待完成: 仍待完成:
@@ -289,6 +300,15 @@
7. 根据条件补最小回归测试或最小验证脚本 7. 根据条件补最小回归测试或最小验证脚本
重点验证 shortcut、undo、scene dirty/save switch、drag-drop/reparent。 重点验证 shortcut、undo、scene dirty/save switch、drag-drop/reparent。
当前新增验证:
- 已新增 `tests/editor/test_action_routing.cpp`
- 已新增 `tests/editor/CMakeLists.txt``editor_tests` target
- 已覆盖 `Hierarchy Edit route` 的 copy / paste / duplicate / delete / rename request
- 已覆盖 `Project Edit route` 的 open / back / delete
- 已覆盖 `scene dirty save + load` 后的 selection / undo reset
- 已覆盖 `reparent` 的 parent 切换、cycle 拦截与 world position / scale 保持
## 下一阶段建议执行顺序 ## 下一阶段建议执行顺序
### 第一阶段 ### 第一阶段

View File

@@ -24,6 +24,26 @@ inline void DrawConsoleToolbarActions(Debug::EditorConsoleSink& sink, UI::Consol
DrawToolbarToggleAction(MakeConsoleErrorFilterAction(filterState.ShowError()), filterState.ShowError()); DrawToolbarToggleAction(MakeConsoleErrorFilterAction(filterState.ShowError()), filterState.ShowError());
} }
inline void DrawConsoleLogRows(const Debug::EditorConsoleSink& sink, const UI::ConsoleFilterState& filterState) {
const auto logs = sink.GetLogs();
size_t logIndex = 0;
for (const auto& log : logs) {
if (!filterState.Allows(log.level)) {
continue;
}
ImGui::PushID(static_cast<int>(logIndex));
const std::string fullMessage = UI::BuildConsoleLogText(log);
if (UI::DrawConsoleLogRow(fullMessage.c_str())) {
ImGui::SetClipboardText(fullMessage.c_str());
}
ImGui::PopID();
++logIndex;
}
}
} // namespace Actions } // namespace Actions
} // namespace Editor } // namespace Editor
} // namespace XCEngine } // namespace XCEngine

View File

@@ -31,96 +31,173 @@ inline ActionBinding MakeDisabledPasteAction() {
return MakeAction("Paste", "Ctrl+V", false, false); return MakeAction("Paste", "Ctrl+V", false, false);
} }
inline void HandleProjectEditShortcuts( inline ActionBinding MakeOpenSelectionAction(const EditActionTarget& target) {
IEditorContext& context, return MakeOpenAssetAction(target.route == EditorActionRoute::Project && Commands::CanOpenAsset(target.selectedAssetItem));
const EditActionTarget& target,
const ShortcutContext& shortcutContext) {
auto& projectManager = context.GetProjectManager();
HandleShortcut(MakeNavigateBackAction(projectManager.CanNavigateBack()), shortcutContext, [&]() {
projectManager.NavigateBack();
});
HandleShortcut(MakeOpenAssetAction(Commands::CanOpenAsset(target.selectedAssetItem)), shortcutContext, [&]() {
Commands::OpenAsset(context, target.selectedAssetItem);
});
HandleShortcut(MakeDeleteAssetAction(target.selectedAssetItem != nullptr), shortcutContext, [&]() {
Commands::DeleteAsset(projectManager, projectManager.GetSelectedIndex());
});
} }
inline void HandleHierarchyEditShortcuts( inline ActionBinding MakeDeleteSelectionAction(const EditActionTarget& target) {
IEditorContext& context, if (target.route == EditorActionRoute::Project) {
const EditActionTarget& target, return MakeDeleteAssetAction(target.selectedAssetItem != nullptr);
const ShortcutContext& shortcutContext) { }
HandleShortcut(MakeDeleteEntityAction(target.selectedGameObject), shortcutContext, [&]() {
Commands::DeleteEntity(context, target.selectedGameObject->GetID()); return MakeDeleteEntityAction(target.route == EditorActionRoute::Hierarchy ? target.selectedGameObject : nullptr);
}); }
HandleShortcut(MakeRenameEntityAction(target.selectedGameObject), shortcutContext, [&]() {
context.GetEventBus().Publish(EntityRenameRequestedEvent{ target.selectedGameObject->GetID() }); inline ActionBinding MakeRenameSelectionAction(const EditActionTarget& target) {
}); return MakeRenameEntityAction(target.route == EditorActionRoute::Hierarchy ? target.selectedGameObject : nullptr);
HandleShortcut(MakeCopyEntityAction(target.selectedGameObject), shortcutContext, [&]() { }
Commands::CopyEntity(context, target.selectedGameObject->GetID());
}); inline ActionBinding MakeCopySelectionAction(const EditActionTarget& target) {
HandleShortcut(MakePasteEntityAction(context), shortcutContext, [&]() { return MakeCopyEntityAction(target.route == EditorActionRoute::Hierarchy ? target.selectedGameObject : nullptr);
Commands::PasteEntity(context, target.selectedGameObject ? target.selectedGameObject->GetID() : 0); }
});
HandleShortcut(MakeDuplicateEntityAction(target.selectedGameObject), shortcutContext, [&]() { inline ActionBinding MakePasteSelectionAction(IEditorContext& context, const EditActionTarget& target) {
Commands::DuplicateEntity(context, target.selectedGameObject->GetID()); return target.route == EditorActionRoute::Hierarchy ? MakePasteEntityAction(context) : MakeDisabledPasteAction();
}); }
inline ActionBinding MakeDuplicateSelectionAction(const EditActionTarget& target) {
return MakeDuplicateEntityAction(target.route == EditorActionRoute::Hierarchy ? target.selectedGameObject : nullptr);
}
inline ActionBinding MakeNavigateBackSelectionAction(IEditorContext& context, const EditActionTarget& target) {
const bool enabled =
target.route == EditorActionRoute::Project && context.GetProjectManager().CanNavigateBack();
return MakeNavigateBackAction(enabled);
}
inline bool ExecuteOpenSelection(IEditorContext& context, const EditActionTarget& target) {
if (target.route != EditorActionRoute::Project || !Commands::CanOpenAsset(target.selectedAssetItem)) {
return false;
}
Commands::OpenAsset(context, target.selectedAssetItem);
return true;
}
inline bool ExecuteDeleteSelection(IEditorContext& context, const EditActionTarget& target) {
if (target.route == EditorActionRoute::Project) {
auto& projectManager = context.GetProjectManager();
if (!target.selectedAssetItem || projectManager.GetSelectedIndex() < 0) {
return false;
}
Commands::DeleteAsset(projectManager, projectManager.GetSelectedIndex());
return true;
}
if (target.route != EditorActionRoute::Hierarchy || !target.selectedGameObject) {
return false;
}
Commands::DeleteEntity(context, target.selectedGameObject->GetID());
return true;
}
inline bool ExecuteNavigateBackSelection(IEditorContext& context, const EditActionTarget& target) {
if (target.route != EditorActionRoute::Project) {
return false;
}
auto& projectManager = context.GetProjectManager();
if (!projectManager.CanNavigateBack()) {
return false;
}
projectManager.NavigateBack();
return true;
}
inline bool ExecuteRenameSelection(IEditorContext& context, const EditActionTarget& target) {
if (target.route != EditorActionRoute::Hierarchy || !target.selectedGameObject) {
return false;
}
context.GetEventBus().Publish(EntityRenameRequestedEvent{ target.selectedGameObject->GetID() });
return true;
}
inline bool ExecuteCopySelection(IEditorContext& context, const EditActionTarget& target) {
if (target.route != EditorActionRoute::Hierarchy || !target.selectedGameObject) {
return false;
}
Commands::CopyEntity(context, target.selectedGameObject->GetID());
return true;
}
inline bool ExecutePasteSelection(IEditorContext& context, const EditActionTarget& target) {
if (target.route != EditorActionRoute::Hierarchy || !context.GetSceneManager().HasClipboardData()) {
return false;
}
Commands::PasteEntity(context, target.selectedGameObject ? target.selectedGameObject->GetID() : 0);
return true;
}
inline bool ExecuteDuplicateSelection(IEditorContext& context, const EditActionTarget& target) {
if (target.route != EditorActionRoute::Hierarchy || !target.selectedGameObject) {
return false;
}
Commands::DuplicateEntity(context, target.selectedGameObject->GetID());
return true;
} }
inline void HandleEditShortcuts(IEditorContext& context, const ShortcutContext& shortcutContext) { inline void HandleEditShortcuts(IEditorContext& context, const ShortcutContext& shortcutContext) {
const EditActionTarget target = ResolveEditActionTarget(context); const EditActionTarget target = ResolveEditActionTarget(context);
switch (target.route) { HandleShortcut(MakeNavigateBackSelectionAction(context, target), shortcutContext, [&]() {
case EditorActionRoute::Project: ExecuteNavigateBackSelection(context, target);
HandleProjectEditShortcuts(context, target, shortcutContext); });
return; HandleShortcut(MakeOpenSelectionAction(target), shortcutContext, [&]() {
case EditorActionRoute::Hierarchy: ExecuteOpenSelection(context, target);
HandleHierarchyEditShortcuts(context, target, shortcutContext); });
return; HandleShortcut(MakeDeleteSelectionAction(target), shortcutContext, [&]() {
case EditorActionRoute::None: ExecuteDeleteSelection(context, target);
return; });
} HandleShortcut(MakeRenameSelectionAction(target), shortcutContext, [&]() {
ExecuteRenameSelection(context, target);
});
HandleShortcut(MakeCopySelectionAction(target), shortcutContext, [&]() {
ExecuteCopySelection(context, target);
});
HandleShortcut(MakePasteSelectionAction(context, target), shortcutContext, [&]() {
ExecutePasteSelection(context, target);
});
HandleShortcut(MakeDuplicateSelectionAction(target), shortcutContext, [&]() {
ExecuteDuplicateSelection(context, target);
});
} }
inline void DrawProjectEditActions(IEditorContext& context, const EditActionTarget& target) { inline void DrawProjectEditActions(IEditorContext& context, const EditActionTarget& target) {
auto& projectManager = context.GetProjectManager(); DrawMenuAction(MakeOpenSelectionAction(target), [&]() {
ExecuteOpenSelection(context, target);
DrawMenuAction(MakeOpenAssetAction(Commands::CanOpenAsset(target.selectedAssetItem)), [&]() {
Commands::OpenAsset(context, target.selectedAssetItem);
}); });
DrawMenuAction(MakeDeleteAssetAction(target.selectedAssetItem != nullptr), [&]() { DrawMenuAction(MakeDeleteSelectionAction(target), [&]() {
Commands::DeleteAsset(projectManager, projectManager.GetSelectedIndex()); ExecuteDeleteSelection(context, target);
}); });
DrawMenuSeparator(); DrawMenuSeparator();
DrawMenuAction(MakeNavigateBackAction(projectManager.CanNavigateBack()), [&]() { DrawMenuAction(MakeNavigateBackSelectionAction(context, target), [&]() {
projectManager.NavigateBack(); ExecuteNavigateBackSelection(context, target);
}); });
} }
inline void DrawHierarchyEditActions(IEditorContext& context, const EditActionTarget& target) { inline void DrawHierarchyEditActions(IEditorContext& context, const EditActionTarget& target) {
const bool hierarchyRouteActive = target.route == EditorActionRoute::Hierarchy;
const ::XCEngine::Components::GameObject* activeObject =
hierarchyRouteActive ? target.selectedGameObject : nullptr;
DrawMenuAction(MakeCutAction(false), []() {}); DrawMenuAction(MakeCutAction(false), []() {});
DrawMenuAction(MakeCopyEntityAction(const_cast<::XCEngine::Components::GameObject*>(activeObject)), [&]() { DrawMenuAction(MakeCopySelectionAction(target), [&]() {
Commands::CopyEntity(context, target.selectedGameObject->GetID()); ExecuteCopySelection(context, target);
}); });
DrawMenuAction( DrawMenuAction(MakePasteSelectionAction(context, target), [&]() {
hierarchyRouteActive ? MakePasteEntityAction(context) : MakeDisabledPasteAction(), ExecutePasteSelection(context, target);
[&]() {
Commands::PasteEntity(context, target.selectedGameObject ? target.selectedGameObject->GetID() : 0);
});
DrawMenuAction(MakeDuplicateEntityAction(const_cast<::XCEngine::Components::GameObject*>(activeObject)), [&]() {
Commands::DuplicateEntity(context, target.selectedGameObject->GetID());
}); });
DrawMenuAction(MakeDeleteEntityAction(const_cast<::XCEngine::Components::GameObject*>(activeObject)), [&]() { DrawMenuAction(MakeDuplicateSelectionAction(target), [&]() {
Commands::DeleteEntity(context, target.selectedGameObject->GetID()); ExecuteDuplicateSelection(context, target);
}); });
DrawMenuAction(MakeRenameEntityAction(const_cast<::XCEngine::Components::GameObject*>(activeObject)), [&]() { DrawMenuAction(MakeDeleteSelectionAction(target), [&]() {
context.GetEventBus().Publish(EntityRenameRequestedEvent{ target.selectedGameObject->GetID() }); ExecuteDeleteSelection(context, target);
});
DrawMenuAction(MakeRenameSelectionAction(target), [&]() {
ExecuteRenameSelection(context, target);
}); });
} }

View File

@@ -5,6 +5,7 @@
#include "Core/EditorEvents.h" #include "Core/EditorEvents.h"
#include "Core/EventBus.h" #include "Core/EventBus.h"
#include "Core/IEditorContext.h" #include "Core/IEditorContext.h"
#include "UI/PopupState.h"
namespace XCEngine { namespace XCEngine {
namespace Editor { namespace Editor {
@@ -22,6 +23,15 @@ inline void RequestEntityRename(IEditorContext& context, const ::XCEngine::Compo
context.GetEventBus().Publish(EntityRenameRequestedEvent{ gameObject->GetID() }); context.GetEventBus().Publish(EntityRenameRequestedEvent{ gameObject->GetID() });
} }
inline bool CommitEntityRename(IEditorContext& context, uint64_t entityId, const char* newName) {
if (entityId == 0 || !newName || newName[0] == '\0' || !context.GetSceneManager().GetEntity(entityId)) {
return false;
}
Commands::RenameEntity(context, entityId, newName);
return true;
}
inline void HandleHierarchySelectionClick(IEditorContext& context, uint64_t entityId, bool additive) { inline void HandleHierarchySelectionClick(IEditorContext& context, uint64_t entityId, bool additive) {
auto& selectionManager = context.GetSelectionManager(); auto& selectionManager = context.GetSelectionManager();
if (additive) { if (additive) {
@@ -34,6 +44,59 @@ inline void HandleHierarchySelectionClick(IEditorContext& context, uint64_t enti
selectionManager.SetSelectedEntity(entityId); selectionManager.SetSelectedEntity(entityId);
} }
template <size_t BufferCapacity>
inline void HandleHierarchyBackgroundPrimaryClick(
IEditorContext& context,
const UI::InlineTextEditState<uint64_t, BufferCapacity>& renameState) {
if (!ImGui::IsWindowHovered() || !ImGui::IsMouseDown(0) || ImGui::IsAnyItemHovered() || renameState.IsActive()) {
return;
}
context.GetSelectionManager().ClearSelection();
}
inline void RequestHierarchyOptionsPopup(UI::DeferredPopupState& optionsPopup) {
optionsPopup.RequestOpen();
}
template <typename SortMode, typename SetSortModeFn>
inline void DrawHierarchySortOptionsPopup(
UI::DeferredPopupState& optionsPopup,
SortMode currentMode,
SortMode nameMode,
SortMode componentCountMode,
SortMode transformFirstMode,
SetSortModeFn&& setSortMode) {
optionsPopup.ConsumeOpenRequest("HierarchyOptions");
if (!UI::BeginPopup("HierarchyOptions")) {
return;
}
const UI::MenuCommand commands[] = {
UI::MenuCommand::Action("Sort By Name", nullptr, currentMode == nameMode),
UI::MenuCommand::Action("Sort By Component Count", nullptr, currentMode == componentCountMode),
UI::MenuCommand::Action("Transform First", nullptr, currentMode == transformFirstMode)
};
UI::DrawMenuCommands(commands, [&](size_t index) {
switch (index) {
case 0:
setSortMode(nameMode);
break;
case 1:
setSortMode(componentCountMode);
break;
case 2:
setSortMode(transformFirstMode);
break;
default:
break;
}
});
UI::EndPopup();
}
inline bool BeginHierarchyEntityDrag(::XCEngine::Components::GameObject* gameObject) { inline bool BeginHierarchyEntityDrag(::XCEngine::Components::GameObject* gameObject) {
if (!gameObject || !ImGui::BeginDragDropSource(ImGuiDragDropFlags_None)) { if (!gameObject || !ImGui::BeginDragDropSource(ImGuiDragDropFlags_None)) {
return false; return false;
@@ -79,6 +142,11 @@ inline bool AcceptHierarchyEntityDropToRoot(IEditorContext& context) {
return accepted; return accepted;
} }
inline void DrawHierarchyRootDropTarget(IEditorContext& context) {
ImGui::InvisibleButton("##DragTarget", ImVec2(-1, -1));
AcceptHierarchyEntityDropToRoot(context);
}
inline void DrawHierarchyCreateActions(IEditorContext& context, ::XCEngine::Components::GameObject* parent) { inline void DrawHierarchyCreateActions(IEditorContext& context, ::XCEngine::Components::GameObject* parent) {
DrawMenuAction(MakeCreateEmptyEntityAction(), [&]() { DrawMenuAction(MakeCreateEmptyEntityAction(), [&]() {
Commands::CreateEmptyEntity(context, parent, "Create Entity", "GameObject"); Commands::CreateEmptyEntity(context, parent, "Create Entity", "GameObject");
@@ -102,6 +170,15 @@ inline void DrawHierarchyCreateActions(IEditorContext& context, ::XCEngine::Comp
}); });
} }
inline void DrawHierarchyBackgroundContextPopup(IEditorContext& context) {
if (!UI::BeginPopupContextWindow("HierarchyContextMenu", ImGuiPopupFlags_MouseButtonRight)) {
return;
}
DrawHierarchyCreateActions(context, nullptr);
UI::EndPopup();
}
inline void DrawHierarchyContextActions(IEditorContext& context, ::XCEngine::Components::GameObject* gameObject) { inline void DrawHierarchyContextActions(IEditorContext& context, ::XCEngine::Components::GameObject* gameObject) {
if (UI::DrawMenuScope("Create", [&]() { if (UI::DrawMenuScope("Create", [&]() {
DrawHierarchyCreateActions(context, gameObject); DrawHierarchyCreateActions(context, gameObject);
@@ -133,6 +210,15 @@ inline void DrawHierarchyContextActions(IEditorContext& context, ::XCEngine::Com
}); });
} }
inline void DrawHierarchyEntityContextPopup(IEditorContext& context, ::XCEngine::Components::GameObject* gameObject) {
if (!UI::BeginPopupContextItem("EntityContextMenu")) {
return;
}
DrawHierarchyContextActions(context, gameObject);
UI::EndPopup();
}
} // namespace Actions } // namespace Actions
} // namespace Editor } // namespace Editor
} // namespace XCEngine } // namespace XCEngine

View File

@@ -3,7 +3,9 @@
#include "EditorActions.h" #include "EditorActions.h"
#include "Commands/ComponentCommands.h" #include "Commands/ComponentCommands.h"
#include "ComponentEditors/ComponentEditorRegistry.h" #include "ComponentEditors/ComponentEditorRegistry.h"
#include "Core/EditorEvents.h"
#include "Core/IEditorContext.h" #include "Core/IEditorContext.h"
#include "UI/PopupState.h"
#include "UI/UI.h" #include "UI/UI.h"
#include <string> #include <string>
@@ -28,6 +30,28 @@ inline std::string BuildAddComponentMenuLabel(const IComponentEditor& editor, ::
return label; return label;
} }
inline void HandleInspectorSelectionChanged(
IEditorContext& context,
const SelectionChangedEvent& event,
uint64_t& selectedEntityId,
UI::DeferredPopupState& addComponentPopup) {
if (context.GetUndoManager().HasPendingInteractiveChange()) {
context.GetUndoManager().FinalizeInteractiveChange();
}
selectedEntityId = event.primarySelection;
addComponentPopup.Clear();
}
inline bool DrawInspectorAddComponentButton(UI::DeferredPopupState& addComponentPopup, bool enabled = true) {
if (!DrawInspectorAction(MakeAddComponentButtonAction(enabled))) {
return false;
}
addComponentPopup.RequestOpen();
return true;
}
inline bool DrawInspectorAddComponentMenu(IEditorContext& context, ::XCEngine::Components::GameObject* gameObject) { inline bool DrawInspectorAddComponentMenu(IEditorContext& context, ::XCEngine::Components::GameObject* gameObject) {
bool drewAnyEntry = false; bool drewAnyEntry = false;
@@ -49,6 +73,23 @@ inline bool DrawInspectorAddComponentMenu(IEditorContext& context, ::XCEngine::C
return drewAnyEntry; return drewAnyEntry;
} }
inline void DrawInspectorAddComponentPopup(
IEditorContext& context,
UI::DeferredPopupState& addComponentPopup,
::XCEngine::Components::GameObject* gameObject) {
addComponentPopup.ConsumeOpenRequest("AddComponent");
if (!UI::BeginTitledPopup("AddComponent", "Components")) {
return;
}
if (!DrawInspectorAddComponentMenu(context, gameObject)) {
UI::DrawHintText("No registered component editors");
}
UI::EndTitledPopup();
}
inline bool DrawInspectorComponentMenu( inline bool DrawInspectorComponentMenu(
IEditorContext& context, IEditorContext& context,
::XCEngine::Components::Component* component, ::XCEngine::Components::Component* component,
@@ -60,6 +101,12 @@ inline bool DrawInspectorComponentMenu(
}); });
} }
inline void FinalizeInspectorInteractiveChangeIfIdle(IEditorContext& context) {
if (context.GetUndoManager().HasPendingInteractiveChange() && !ImGui::IsAnyItemActive()) {
context.GetUndoManager().FinalizeInteractiveChange();
}
}
} // namespace Actions } // namespace Actions
} // namespace Editor } // namespace Editor
} // namespace XCEngine } // namespace XCEngine

View File

@@ -7,42 +7,102 @@
#include "Core/EventBus.h" #include "Core/EventBus.h"
#include "Core/IEditorContext.h" #include "Core/IEditorContext.h"
#include "UI/PopupState.h" #include "UI/PopupState.h"
#include "UI/UI.h"
namespace XCEngine { namespace XCEngine {
namespace Editor { namespace Editor {
namespace Actions { namespace Actions {
inline void ExecuteNewScene(IEditorContext& context) {
Commands::NewScene(context);
}
inline void ExecuteOpenScene(IEditorContext& context) {
Commands::OpenSceneWithDialog(context);
}
inline void ExecuteSaveScene(IEditorContext& context) {
Commands::SaveCurrentScene(context);
}
inline void ExecuteSaveSceneAs(IEditorContext& context) {
Commands::SaveSceneAsWithDialog(context);
}
inline void ExecuteUndo(IEditorContext& context) {
context.GetUndoManager().Undo();
}
inline void ExecuteRedo(IEditorContext& context) {
context.GetUndoManager().Redo();
}
inline void RequestEditorExit(IEditorContext& context) {
context.GetEventBus().Publish(EditorExitRequestedEvent{});
}
inline void RequestDockLayoutReset(IEditorContext& context) {
context.GetEventBus().Publish(DockLayoutResetRequestedEvent{});
}
inline void RequestAboutPopup(UI::DeferredPopupState& aboutPopup) {
aboutPopup.RequestOpen();
}
inline void HandleMainMenuShortcuts(IEditorContext& context, const ShortcutContext& shortcutContext) { inline void HandleMainMenuShortcuts(IEditorContext& context, const ShortcutContext& shortcutContext) {
HandleShortcut(MakeNewSceneAction(), shortcutContext, [&]() { Commands::NewScene(context); }); HandleShortcut(MakeNewSceneAction(), shortcutContext, [&]() { ExecuteNewScene(context); });
HandleShortcut(MakeOpenSceneAction(), shortcutContext, [&]() { Commands::OpenSceneWithDialog(context); }); HandleShortcut(MakeOpenSceneAction(), shortcutContext, [&]() { ExecuteOpenScene(context); });
HandleShortcut(MakeSaveSceneAction(), shortcutContext, [&]() { Commands::SaveCurrentScene(context); }); HandleShortcut(MakeSaveSceneAction(), shortcutContext, [&]() { ExecuteSaveScene(context); });
HandleShortcut(MakeSaveSceneAsAction(), shortcutContext, [&]() { Commands::SaveSceneAsWithDialog(context); }); HandleShortcut(MakeSaveSceneAsAction(), shortcutContext, [&]() { ExecuteSaveSceneAs(context); });
HandleShortcut(MakeUndoAction(context), shortcutContext, [&]() { context.GetUndoManager().Undo(); }); HandleShortcut(MakeUndoAction(context), shortcutContext, [&]() { ExecuteUndo(context); });
HandleShortcut(MakeRedoAction(context), shortcutContext, [&]() { context.GetUndoManager().Redo(); }); HandleShortcut(MakeRedoAction(context), shortcutContext, [&]() { ExecuteRedo(context); });
HandleEditShortcuts(context, shortcutContext); HandleEditShortcuts(context, shortcutContext);
} }
inline void DrawFileMenuActions(IEditorContext& context) { inline void DrawFileMenuActions(IEditorContext& context) {
DrawMenuAction(MakeNewSceneAction(), [&]() { Commands::NewScene(context); }); DrawMenuAction(MakeNewSceneAction(), [&]() { ExecuteNewScene(context); });
DrawMenuAction(MakeOpenSceneAction(), [&]() { Commands::OpenSceneWithDialog(context); }); DrawMenuAction(MakeOpenSceneAction(), [&]() { ExecuteOpenScene(context); });
DrawMenuAction(MakeSaveSceneAction(), [&]() { Commands::SaveCurrentScene(context); }); DrawMenuAction(MakeSaveSceneAction(), [&]() { ExecuteSaveScene(context); });
DrawMenuAction(MakeSaveSceneAsAction(), [&]() { Commands::SaveSceneAsWithDialog(context); }); DrawMenuAction(MakeSaveSceneAsAction(), [&]() { ExecuteSaveSceneAs(context); });
DrawMenuSeparator(); DrawMenuSeparator();
DrawMenuAction(MakeExitAction(), [&]() { DrawMenuAction(MakeExitAction(), [&]() { RequestEditorExit(context); });
context.GetEventBus().Publish(EditorExitRequestedEvent{});
});
} }
inline void DrawViewMenuActions(IEditorContext& context) { inline void DrawViewMenuActions(IEditorContext& context) {
DrawMenuAction(MakeResetLayoutAction(), [&]() { DrawMenuAction(MakeResetLayoutAction(), [&]() { RequestDockLayoutReset(context); });
context.GetEventBus().Publish(DockLayoutResetRequestedEvent{});
});
} }
inline void DrawHelpMenuActions(UI::DeferredPopupState& aboutPopup) { inline void DrawHelpMenuActions(UI::DeferredPopupState& aboutPopup) {
DrawMenuAction(MakeAboutAction(), [&]() { DrawMenuAction(MakeAboutAction(), [&]() { RequestAboutPopup(aboutPopup); });
aboutPopup.RequestOpen(); }
inline void HandleMenuBarShortcuts(IEditorContext& context) {
HandleMainMenuShortcuts(context, GlobalShortcutContext());
}
inline void DrawMainMenuBar(IEditorContext& context, UI::DeferredPopupState& aboutPopup) {
if (!ImGui::BeginMainMenuBar()) {
return;
}
UI::DrawMenuScope("File", [&]() {
DrawFileMenuActions(context);
}); });
UI::DrawMenuScope("Edit", [&]() {
DrawEditActions(context);
});
UI::DrawMenuScope("View", [&]() {
DrawViewMenuActions(context);
});
UI::DrawMenuScope("Help", [&]() {
DrawHelpMenuActions(aboutPopup);
});
UI::DrawSceneStatusWidget(context);
ImGui::EndMainMenuBar();
}
inline void DrawMainMenuOverlays(IEditorContext* context, UI::DeferredPopupState& aboutPopup) {
UI::DrawEditorAboutDialog(context, aboutPopup);
} }
} // namespace Actions } // namespace Actions

View File

@@ -14,6 +14,8 @@ inline constexpr const char* ProjectAssetPayloadType() {
return "ASSET_ITEM"; return "ASSET_ITEM";
} }
inline void DrawProjectAssetContextActions(IEditorContext& context, const AssetItemPtr& item);
inline int FindProjectItemIndex(IProjectManager& projectManager, const AssetItemPtr& item) { inline int FindProjectItemIndex(IProjectManager& projectManager, const AssetItemPtr& item) {
if (!item) { if (!item) {
return -1; return -1;
@@ -77,6 +79,64 @@ inline bool BeginProjectAssetDrag(const AssetItemPtr& item, UI::AssetIconKind ic
return true; return true;
} }
inline bool OpenProjectAsset(IEditorContext& context, const AssetItemPtr& item) {
if (!Commands::CanOpenAsset(item)) {
return false;
}
Commands::OpenAsset(context, item);
return true;
}
inline bool DrawProjectNavigateBackAction(IProjectManager& projectManager) {
const bool canGoBack = projectManager.CanNavigateBack();
if (!DrawToolbarAction(MakeNavigateBackAction(canGoBack), UI::ProjectBackButtonSize())) {
return false;
}
projectManager.NavigateBack();
return true;
}
inline void HandleProjectBackgroundPrimaryClick(IProjectManager& projectManager) {
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0) && !ImGui::IsAnyItemHovered()) {
projectManager.SetSelectedIndex(-1);
}
}
inline void RequestProjectEmptyContextPopup(UI::DeferredPopupState& emptyContextMenu) {
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(1) && !ImGui::IsAnyItemHovered()) {
emptyContextMenu.RequestOpen();
}
}
inline void HandleProjectItemSelection(IProjectManager& projectManager, int index) {
projectManager.SetSelectedIndex(index);
}
inline void HandleProjectItemContextRequest(
IProjectManager& projectManager,
int index,
const AssetItemPtr& item,
UI::TargetedPopupState<AssetItemPtr>& itemContextMenu) {
projectManager.SetSelectedIndex(index);
itemContextMenu.RequestOpen(item);
}
inline void DrawProjectItemContextPopup(IEditorContext& context, UI::TargetedPopupState<AssetItemPtr>& itemContextMenu) {
itemContextMenu.ConsumeOpenRequest("ItemContextMenu");
if (UI::BeginPopup("ItemContextMenu")) {
if (itemContextMenu.HasTarget()) {
DrawProjectAssetContextActions(context, itemContextMenu.TargetValue());
}
UI::EndPopup();
}
if (!ImGui::IsPopupOpen("ItemContextMenu") && !itemContextMenu.HasPendingOpenRequest()) {
itemContextMenu.Clear();
}
}
inline void DrawProjectAssetContextActions(IEditorContext& context, const AssetItemPtr& item) { inline void DrawProjectAssetContextActions(IEditorContext& context, const AssetItemPtr& item) {
auto& projectManager = context.GetProjectManager(); auto& projectManager = context.GetProjectManager();
const int itemIndex = FindProjectItemIndex(projectManager, item); const int itemIndex = FindProjectItemIndex(projectManager, item);
@@ -97,6 +157,20 @@ inline void DrawProjectEmptyContextActions(UI::TextInputPopupState<BufferCapacit
}); });
} }
template <size_t BufferCapacity>
inline void DrawProjectEmptyContextPopup(
UI::DeferredPopupState& emptyContextMenu,
UI::TextInputPopupState<BufferCapacity>& createFolderDialog) {
emptyContextMenu.ConsumeOpenRequest("EmptyContextMenu");
if (!UI::BeginPopup("EmptyContextMenu")) {
return;
}
DrawProjectEmptyContextActions(createFolderDialog);
UI::EndPopup();
}
template <size_t BufferCapacity> template <size_t BufferCapacity>
inline void DrawProjectCreateFolderDialog(IEditorContext& context, UI::TextInputPopupState<BufferCapacity>& createFolderDialog) { inline void DrawProjectCreateFolderDialog(IEditorContext& context, UI::TextInputPopupState<BufferCapacity>& createFolderDialog) {
createFolderDialog.ConsumeOpenRequest("Create Folder"); createFolderDialog.ConsumeOpenRequest("Create Folder");

View File

@@ -17,6 +17,62 @@ Application& Application::Get() {
return instance; return instance;
} }
bool Application::InitializeWindowRenderer(HWND hwnd) {
if (m_windowRenderer.Initialize(hwnd, 1280, 720)) {
return true;
}
MessageBoxW(hwnd, L"Failed to create D3D12 device", L"Error", MB_OK | MB_ICONERROR);
return false;
}
void Application::InitializeEditorContext(const std::string& projectPath) {
m_editorContext = std::make_shared<EditorContext>();
m_editorContext->SetProjectPath(projectPath);
m_exitRequestedHandlerId = m_editorContext->GetEventBus().Subscribe<EditorExitRequestedEvent>(
[this](const EditorExitRequestedEvent&) {
if (m_hwnd) {
PostMessageW(m_hwnd, WM_CLOSE, 0, 0);
}
});
}
void Application::InitializeImGui(HWND hwnd) {
m_imguiSession.Initialize(m_editorContext->GetProjectPath());
m_imguiBackend.Initialize(hwnd, m_windowRenderer.GetDevice(), m_windowRenderer.GetSrvHeap());
}
void Application::AttachEditorLayer() {
m_editorLayer = new EditorLayer();
m_editorLayer->SetContext(m_editorContext);
m_layerStack.pushLayer(std::unique_ptr<Core::Layer>(m_editorLayer));
m_layerStack.onAttach();
}
void Application::DetachEditorLayer() {
m_layerStack.onDetach();
m_editorLayer = nullptr;
}
void Application::ShutdownEditorContext() {
if (m_editorContext && m_exitRequestedHandlerId) {
m_editorContext->GetEventBus().Unsubscribe<EditorExitRequestedEvent>(m_exitRequestedHandlerId);
m_exitRequestedHandlerId = 0;
}
m_editorContext.reset();
}
void Application::RenderEditorFrame() {
static constexpr float kClearColor[4] = { 0.22f, 0.22f, 0.22f, 1.0f };
m_imguiBackend.BeginFrame();
m_layerStack.onImGuiRender();
UpdateWindowTitle();
ImGui::Render();
m_windowRenderer.Render(m_imguiBackend, kClearColor);
}
bool Application::Initialize(HWND hwnd) { bool Application::Initialize(HWND hwnd) {
Platform::InstallCrashExceptionFilter(); Platform::InstallCrashExceptionFilter();
Platform::RedirectStderrToExecutableLog(); Platform::RedirectStderrToExecutableLog();
@@ -26,54 +82,26 @@ bool Application::Initialize(HWND hwnd) {
m_hwnd = hwnd; m_hwnd = hwnd;
if (!m_windowRenderer.Initialize(hwnd, 1280, 720)) { if (!InitializeWindowRenderer(hwnd)) {
MessageBoxW(hwnd, L"Failed to create D3D12 device", L"Error", MB_OK | MB_ICONERROR);
return false; return false;
} }
m_editorContext = std::make_shared<EditorContext>(); InitializeEditorContext(exeDir);
m_editorContext->SetProjectPath(exeDir); InitializeImGui(hwnd);
m_exitRequestedHandlerId = m_editorContext->GetEventBus().Subscribe<EditorExitRequestedEvent>( AttachEditorLayer();
[this](const EditorExitRequestedEvent&) {
if (m_hwnd) {
PostMessageW(m_hwnd, WM_CLOSE, 0, 0);
}
});
m_imguiSession.Initialize(m_editorContext->GetProjectPath());
m_imguiBackend.Initialize(hwnd, m_windowRenderer.GetDevice(), m_windowRenderer.GetSrvHeap());
m_editorLayer = new EditorLayer();
m_editorLayer->SetContext(m_editorContext);
m_layerStack.pushLayer(std::unique_ptr<Core::Layer>(m_editorLayer));
m_layerStack.onAttach();
return true; return true;
} }
void Application::Shutdown() { void Application::Shutdown() {
m_layerStack.onDetach(); DetachEditorLayer();
if (m_editorContext && m_exitRequestedHandlerId) {
m_editorContext->GetEventBus().Unsubscribe<EditorExitRequestedEvent>(m_exitRequestedHandlerId);
m_exitRequestedHandlerId = 0;
}
m_imguiBackend.Shutdown(); m_imguiBackend.Shutdown();
m_imguiSession.Shutdown(); m_imguiSession.Shutdown();
ShutdownEditorContext();
m_windowRenderer.Shutdown(); m_windowRenderer.Shutdown();
} }
void Application::Render() { void Application::Render() {
m_imguiBackend.BeginFrame(); RenderEditorFrame();
m_layerStack.onImGuiRender();
UpdateWindowTitle();
ImGui::Render();
float clearColor[4] = { 0.22f, 0.22f, 0.22f, 1.0f };
m_windowRenderer.Render(m_imguiBackend, clearColor);
} }
void Application::UpdateWindowTitle() { void Application::UpdateWindowTitle() {

View File

@@ -31,6 +31,13 @@ public:
private: private:
Application() = default; Application() = default;
~Application() = default; ~Application() = default;
bool InitializeWindowRenderer(HWND hwnd);
void InitializeEditorContext(const std::string& projectPath);
void InitializeImGui(HWND hwnd);
void AttachEditorLayer();
void DetachEditorLayer();
void ShutdownEditorContext();
void RenderEditorFrame();
void UpdateWindowTitle(); void UpdateWindowTitle();
HWND m_hwnd = nullptr; HWND m_hwnd = nullptr;

View File

@@ -33,24 +33,7 @@ void ConsolePanel::Render() {
return; return;
} }
const auto logs = sink->GetLogs(); Actions::DrawConsoleLogRows(*sink, m_filterState);
size_t logIndex = 0;
for (const auto& log : logs) {
if (!m_filterState.Allows(log.level)) {
continue;
}
ImGui::PushID(static_cast<int>(logIndex));
const std::string fullMessage = UI::BuildConsoleLogText(log);
if (UI::DrawConsoleLogRow(fullMessage.c_str())) {
ImGui::SetClipboardText(fullMessage.c_str());
}
ImGui::PopID();
logIndex++;
}
} }
} }

View File

@@ -88,20 +88,10 @@ void HierarchyPanel::Render() {
RenderEntity(gameObject, filter); RenderEntity(gameObject, filter);
} }
if (ImGui::IsWindowHovered() && ImGui::IsMouseDown(0) && !ImGui::IsAnyItemHovered()) { Actions::HandleHierarchyBackgroundPrimaryClick(*m_context, m_renameState);
if (!m_renameState.IsActive()) {
m_context->GetSelectionManager().ClearSelection();
}
}
if (UI::BeginPopupContextWindow("HierarchyContextMenu", ImGuiPopupFlags_MouseButtonRight)) {
Actions::DrawHierarchyCreateActions(*m_context, nullptr);
UI::EndPopup();
}
ImGui::InvisibleButton("##DragTarget", ImVec2(-1, -1));
Actions::AcceptHierarchyEntityDropToRoot(*m_context);
Actions::DrawHierarchyBackgroundContextPopup(*m_context);
Actions::DrawHierarchyRootDropTarget(*m_context);
} }
void HierarchyPanel::RenderSearchBar() { void HierarchyPanel::RenderSearchBar() {
@@ -119,33 +109,18 @@ void HierarchyPanel::RenderSearchBar() {
buttonWidth + UI::ToolbarSearchTrailingSpacing()); buttonWidth + UI::ToolbarSearchTrailingSpacing());
ImGui::SameLine(); ImGui::SameLine();
if (UI::ToolbarButton("...", false, ImVec2(buttonWidth, 0.0f))) { if (UI::ToolbarButton("...", false, ImVec2(buttonWidth, 0.0f))) {
ImGui::OpenPopup("HierarchyOptions"); Actions::RequestHierarchyOptionsPopup(m_optionsPopup);
} }
if (UI::BeginPopup("HierarchyOptions")) { Actions::DrawHierarchySortOptionsPopup(
const UI::MenuCommand commands[] = { m_optionsPopup,
UI::MenuCommand::Action("Sort By Name", nullptr, m_sortMode == SortMode::Name), m_sortMode,
UI::MenuCommand::Action("Sort By Component Count", nullptr, m_sortMode == SortMode::ComponentCount), SortMode::Name,
UI::MenuCommand::Action("Transform First", nullptr, m_sortMode == SortMode::TransformFirst) SortMode::ComponentCount,
}; SortMode::TransformFirst,
[this](SortMode mode) {
UI::DrawMenuCommands(commands, [&](size_t index) { m_sortMode = mode;
switch (index) {
case 0:
m_sortMode = SortMode::Name;
break;
case 1:
m_sortMode = SortMode::ComponentCount;
break;
case 2:
m_sortMode = SortMode::TransformFirst;
break;
default:
break;
}
}); });
UI::EndPopup();
}
} }
void HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject, const std::string& filter) { void HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject, const std::string& filter) {
@@ -194,10 +169,7 @@ void HierarchyPanel::RenderEntity(::XCEngine::Components::GameObject* gameObject
Actions::BeginHierarchyEntityDrag(gameObject); Actions::BeginHierarchyEntityDrag(gameObject);
Actions::AcceptHierarchyEntityDrop(*m_context, gameObject); Actions::AcceptHierarchyEntityDrop(*m_context, gameObject);
if (UI::BeginPopupContextItem("EntityContextMenu")) { Actions::DrawHierarchyEntityContextPopup(*m_context, gameObject);
Actions::DrawHierarchyContextActions(*m_context, gameObject);
UI::EndPopup();
}
if (node.open) { if (node.open) {
for (size_t i = 0; i < gameObject->GetChildCount(); i++) { for (size_t i = 0; i < gameObject->GetChildCount(); i++) {
@@ -225,10 +197,7 @@ void HierarchyPanel::CommitRename() {
} }
const uint64_t entityId = m_renameState.Item(); const uint64_t entityId = m_renameState.Item();
if (!m_renameState.Empty() && m_context->GetSceneManager().GetEntity(entityId)) { Actions::CommitEntityRename(*m_context, entityId, m_renameState.Buffer());
Commands::RenameEntity(*m_context, entityId, m_renameState.Buffer());
}
CancelRename(); CancelRename();
} }

View File

@@ -31,6 +31,7 @@ private:
char m_searchBuffer[256] = ""; char m_searchBuffer[256] = "";
UI::InlineTextEditState<uint64_t, 256> m_renameState; UI::InlineTextEditState<uint64_t, 256> m_renameState;
UI::DeferredPopupState m_optionsPopup;
SortMode m_sortMode = SortMode::Name; SortMode m_sortMode = SortMode::Name;
uint64_t m_selectionHandlerId = 0; uint64_t m_selectionHandlerId = 0;
uint64_t m_renameRequestHandlerId = 0; uint64_t m_renameRequestHandlerId = 0;

View File

@@ -44,11 +44,7 @@ void InspectorPanel::OnDetach() {
} }
void InspectorPanel::OnSelectionChanged(const SelectionChangedEvent& event) { void InspectorPanel::OnSelectionChanged(const SelectionChangedEvent& event) {
if (m_context && m_context->GetUndoManager().HasPendingInteractiveChange()) { Actions::HandleInspectorSelectionChanged(*m_context, event, m_selectedEntityId, m_addComponentPopup);
m_context->GetUndoManager().FinalizeInteractiveChange();
}
m_selectedEntityId = event.primarySelection;
m_addComponentPopup.Clear();
} }
void InspectorPanel::Render() { void InspectorPanel::Render() {
@@ -90,14 +86,10 @@ void InspectorPanel::RenderGameObject(::XCEngine::Components::GameObject* gameOb
RenderComponent(component, gameObject); RenderComponent(component, gameObject);
} }
if (Actions::DrawInspectorAction(Actions::MakeAddComponentButtonAction(gameObject != nullptr))) { Actions::DrawInspectorAddComponentButton(m_addComponentPopup, gameObject != nullptr);
m_addComponentPopup.RequestOpen(); Actions::DrawInspectorAddComponentPopup(*m_context, m_addComponentPopup, gameObject);
}
RenderAddComponentPopup(gameObject);
if (m_context->GetUndoManager().HasPendingInteractiveChange() && !ImGui::IsAnyItemActive()) { Actions::FinalizeInspectorInteractiveChangeIfIdle(*m_context);
m_context->GetUndoManager().FinalizeInteractiveChange();
}
} }
void InspectorPanel::RenderEmptyState(const char* title, const char* subtitle) { void InspectorPanel::RenderEmptyState(const char* title, const char* subtitle) {
@@ -109,20 +101,6 @@ void InspectorPanel::RenderEmptyState(const char* title, const char* subtitle) {
UI::DrawEmptyState(title, subtitle); UI::DrawEmptyState(title, subtitle);
} }
void InspectorPanel::RenderAddComponentPopup(::XCEngine::Components::GameObject* gameObject) {
m_addComponentPopup.ConsumeOpenRequest("AddComponent");
if (!UI::BeginTitledPopup("AddComponent", "Components")) {
return;
}
if (!Actions::DrawInspectorAddComponentMenu(*m_context, gameObject)) {
UI::DrawHintText("No registered component editors");
}
UI::EndTitledPopup();
}
void InspectorPanel::RenderComponent(::XCEngine::Components::Component* component, ::XCEngine::Components::GameObject* gameObject) { void InspectorPanel::RenderComponent(::XCEngine::Components::Component* component, ::XCEngine::Components::GameObject* gameObject) {
if (!component) return; if (!component) return;

View File

@@ -24,7 +24,6 @@ public:
private: private:
void RenderGameObject(::XCEngine::Components::GameObject* gameObject); void RenderGameObject(::XCEngine::Components::GameObject* gameObject);
void RenderAddComponentPopup(::XCEngine::Components::GameObject* gameObject);
void RenderComponent(::XCEngine::Components::Component* component, ::XCEngine::Components::GameObject* gameObject); void RenderComponent(::XCEngine::Components::Component* component, ::XCEngine::Components::GameObject* gameObject);
void RenderEmptyState(const char* title, const char* subtitle = nullptr); void RenderEmptyState(const char* title, const char* subtitle = nullptr);
void OnSelectionChanged(const struct SelectionChangedEvent& event); void OnSelectionChanged(const struct SelectionChangedEvent& event);

View File

@@ -3,7 +3,6 @@
#include "Actions/MainMenuActionRouter.h" #include "Actions/MainMenuActionRouter.h"
#include "MenuBar.h" #include "MenuBar.h"
#include "Core/IEditorContext.h" #include "Core/IEditorContext.h"
#include "UI/UI.h"
#include <imgui.h> #include <imgui.h>
namespace XCEngine { namespace XCEngine {
@@ -12,61 +11,13 @@ namespace Editor {
MenuBar::MenuBar() : Panel("MenuBar") {} MenuBar::MenuBar() : Panel("MenuBar") {}
void MenuBar::Render() { void MenuBar::Render() {
HandleShortcuts();
if (ImGui::BeginMainMenuBar()) {
ShowFileMenu();
ShowEditMenu();
ShowViewMenu();
ShowHelpMenu();
RenderSceneStatus();
ImGui::EndMainMenuBar();
}
RenderAboutPopup();
}
void MenuBar::HandleShortcuts() {
if (!m_context) { if (!m_context) {
return; return;
} }
Actions::HandleMainMenuShortcuts(*m_context, Actions::GlobalShortcutContext()); Actions::HandleMenuBarShortcuts(*m_context);
} Actions::DrawMainMenuBar(*m_context, m_aboutPopup);
Actions::DrawMainMenuOverlays(m_context, m_aboutPopup);
void MenuBar::ShowFileMenu() {
UI::DrawMenuScope("File", [&]() {
Actions::DrawFileMenuActions(*m_context);
});
}
void MenuBar::ShowEditMenu() {
UI::DrawMenuScope("Edit", [&]() {
Actions::DrawEditActions(*m_context);
});
}
void MenuBar::ShowViewMenu() {
UI::DrawMenuScope("View", [&]() {
Actions::DrawViewMenuActions(*m_context);
});
}
void MenuBar::ShowHelpMenu() {
UI::DrawMenuScope("Help", [&]() {
Actions::DrawHelpMenuActions(m_aboutPopup);
});
}
void MenuBar::RenderAboutPopup() {
UI::DrawEditorAboutDialog(m_context, m_aboutPopup);
}
void MenuBar::RenderSceneStatus() {
if (!m_context) {
return;
}
UI::DrawSceneStatusWidget(*m_context);
} }
} }

View File

@@ -12,14 +12,6 @@ public:
void Render() override; void Render() override;
private: private:
void HandleShortcuts();
void RenderSceneStatus();
void RenderAboutPopup();
void ShowFileMenu();
void ShowEditMenu();
void ShowViewMenu();
void ShowHelpMenu();
UI::DeferredPopupState m_aboutPopup; UI::DeferredPopupState m_aboutPopup;
}; };

View File

@@ -30,11 +30,7 @@ void ProjectPanel::Render() {
UI::PanelToolbarScope toolbar("ProjectToolbar", UI::ProjectPanelToolbarHeight()); UI::PanelToolbarScope toolbar("ProjectToolbar", UI::ProjectPanelToolbarHeight());
if (toolbar.IsOpen()) { if (toolbar.IsOpen()) {
Actions::DrawProjectNavigateBackAction(manager);
bool canGoBack = manager.CanNavigateBack();
if (Actions::DrawToolbarAction(Actions::MakeNavigateBackAction(canGoBack), UI::ProjectBackButtonSize())) {
manager.NavigateBack();
}
ImGui::SameLine(); ImGui::SameLine();
size_t pathDepth = manager.GetPathDepth(); size_t pathDepth = manager.GetPathDepth();
@@ -88,29 +84,10 @@ void ProjectPanel::Render() {
searchStr.empty() ? "Current folder is empty" : "No assets match the current search"); searchStr.empty() ? "Current folder is empty" : "No assets match the current search");
} }
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0) && !ImGui::IsAnyItemHovered()) { Actions::HandleProjectBackgroundPrimaryClick(manager);
manager.SetSelectedIndex(-1); Actions::DrawProjectItemContextPopup(*m_context, m_itemContextMenu);
} Actions::RequestProjectEmptyContextPopup(m_emptyContextMenu);
Actions::DrawProjectEmptyContextPopup(m_emptyContextMenu, m_createFolderDialog);
m_itemContextMenu.ConsumeOpenRequest("ItemContextMenu");
if (UI::BeginPopup("ItemContextMenu")) {
if (m_itemContextMenu.HasTarget()) {
Actions::DrawProjectAssetContextActions(*m_context, m_itemContextMenu.TargetValue());
}
UI::EndPopup();
}
if (!ImGui::IsPopupOpen("ItemContextMenu") && !m_itemContextMenu.HasPendingOpenRequest()) {
m_itemContextMenu.Clear();
}
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(1) && !ImGui::IsAnyItemHovered()) {
ImGui::OpenPopup("EmptyContextMenu");
}
if (UI::BeginPopup("EmptyContextMenu")) {
Actions::DrawProjectEmptyContextActions(m_createFolderDialog);
UI::EndPopup();
}
Actions::DrawProjectCreateFolderDialog(*m_context, m_createFolderDialog); Actions::DrawProjectCreateFolderDialog(*m_context, m_createFolderDialog);
} }
@@ -132,19 +109,18 @@ void ProjectPanel::RenderAssetItem(const AssetItemPtr& item, int index) {
}); });
if (tile.clicked) { if (tile.clicked) {
manager.SetSelectedIndex(index); Actions::HandleProjectItemSelection(manager, index);
} }
if (tile.contextRequested) { if (tile.contextRequested) {
manager.SetSelectedIndex(index); Actions::HandleProjectItemContextRequest(manager, index, item, m_itemContextMenu);
m_itemContextMenu.RequestOpen(item);
} }
Actions::AcceptProjectAssetDrop(manager, item); Actions::AcceptProjectAssetDrop(manager, item);
Actions::BeginProjectAssetDrag(item, iconKind); Actions::BeginProjectAssetDrag(item, iconKind);
if (tile.openRequested) { if (tile.openRequested) {
Commands::OpenAsset(*m_context, item); Actions::OpenProjectAsset(*m_context, item);
} }
ImGui::PopID(); ImGui::PopID();

View File

@@ -18,6 +18,7 @@ private:
char m_searchBuffer[256] = ""; char m_searchBuffer[256] = "";
UI::TextInputPopupState<256> m_createFolderDialog; UI::TextInputPopupState<256> m_createFolderDialog;
UI::DeferredPopupState m_emptyContextMenu;
UI::TargetedPopupState<AssetItemPtr> m_itemContextMenu; UI::TargetedPopupState<AssetItemPtr> m_itemContextMenu;
}; };

View File

@@ -44,6 +44,7 @@ add_subdirectory(Rendering)
add_subdirectory(rhi) add_subdirectory(rhi)
add_subdirectory(resources) add_subdirectory(resources)
add_subdirectory(input) add_subdirectory(input)
add_subdirectory(editor)
# ============================================================ # ============================================================
# Test Summary # Test Summary

View File

@@ -0,0 +1,36 @@
cmake_minimum_required(VERSION 3.15)
project(XCEngine_EditorTests)
set(EDITOR_TEST_SOURCES
test_action_routing.cpp
${CMAKE_SOURCE_DIR}/editor/src/Core/UndoManager.cpp
${CMAKE_SOURCE_DIR}/editor/src/Managers/SceneManager.cpp
${CMAKE_SOURCE_DIR}/editor/src/Managers/ProjectManager.cpp
)
add_executable(editor_tests ${EDITOR_TEST_SOURCES})
if(MSVC)
set_target_properties(editor_tests PROPERTIES
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
)
endif()
target_link_libraries(editor_tests PRIVATE
XCEngine
GTest::gtest
GTest::gtest_main
user32
comdlg32
)
target_include_directories(editor_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/editor/src
${CMAKE_BINARY_DIR}/_deps/imgui-src
${CMAKE_BINARY_DIR}/_deps/imgui-src/backends
)
include(GoogleTest)
gtest_discover_tests(editor_tests)

View File

@@ -0,0 +1,224 @@
#include <gtest/gtest.h>
#include "Actions/EditActionRouter.h"
#include "Commands/EntityCommands.h"
#include "Commands/SceneCommands.h"
#include "Core/EditorContext.h"
#include <XCEngine/Core/Math/Quaternion.h>
#include <XCEngine/Core/Math/Vector3.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <string>
namespace fs = std::filesystem;
namespace XCEngine::Editor {
namespace {
class EditorActionRoutingTest : public ::testing::Test {
protected:
void SetUp() override {
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
m_projectRoot = fs::temp_directory_path() / ("xc_editor_tests_" + std::to_string(stamp));
fs::create_directories(m_projectRoot);
m_context.SetProjectPath(m_projectRoot.string());
m_context.GetProjectManager().Initialize(m_projectRoot.string());
m_context.GetSceneManager().NewScene("Editor Test Scene");
}
void TearDown() override {
std::error_code ec;
fs::remove_all(m_projectRoot, ec);
}
AssetItemPtr FindCurrentItemByName(const std::string& name) {
for (auto& item : m_context.GetProjectManager().GetCurrentItems()) {
if (item && item->name == name) {
return item;
}
}
return nullptr;
}
int FindCurrentItemIndexByName(const std::string& name) {
auto& items = m_context.GetProjectManager().GetCurrentItems();
for (int i = 0; i < static_cast<int>(items.size()); ++i) {
if (items[i] && items[i]->name == name) {
return i;
}
}
return -1;
}
static void ExpectNear(const Math::Vector3& actual, const Math::Vector3& expected, float epsilon = 1e-4f) {
EXPECT_NEAR(actual.x, expected.x, epsilon);
EXPECT_NEAR(actual.y, expected.y, epsilon);
EXPECT_NEAR(actual.z, expected.z, epsilon);
}
static size_t CountHierarchyEntities(const ISceneManager& sceneManager) {
auto countChildren = [](const ::XCEngine::Components::GameObject* gameObject, const auto& self) -> size_t {
if (!gameObject) {
return 0;
}
size_t count = 1;
for (size_t i = 0; i < gameObject->GetChildCount(); ++i) {
count += self(gameObject->GetChild(i), self);
}
return count;
};
size_t total = 0;
for (auto* root : sceneManager.GetRootEntities()) {
total += countChildren(root, countChildren);
}
return total;
}
EditorContext m_context;
fs::path m_projectRoot;
};
TEST_F(EditorActionRoutingTest, HierarchyRouteExecutesCopyPasteDuplicateDeleteAndRename) {
auto* entity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Entity", "Original");
ASSERT_NE(entity, nullptr);
m_context.SetActiveActionRoute(EditorActionRoute::Hierarchy);
m_context.GetSelectionManager().SetSelectedEntity(entity->GetID());
const Actions::EditActionTarget target = Actions::ResolveEditActionTarget(m_context);
ASSERT_EQ(target.route, EditorActionRoute::Hierarchy);
ASSERT_EQ(target.selectedGameObject, entity);
uint64_t renameRequestedId = 0;
const uint64_t renameSubscription = m_context.GetEventBus().Subscribe<EntityRenameRequestedEvent>(
[&](const EntityRenameRequestedEvent& event) {
renameRequestedId = event.entityId;
});
EXPECT_TRUE(Actions::ExecuteRenameSelection(m_context, target));
EXPECT_EQ(renameRequestedId, entity->GetID());
m_context.GetEventBus().Unsubscribe<EntityRenameRequestedEvent>(renameSubscription);
const size_t entityCountBeforePaste = CountHierarchyEntities(m_context.GetSceneManager());
EXPECT_TRUE(Actions::ExecuteCopySelection(m_context, target));
EXPECT_TRUE(m_context.GetSceneManager().HasClipboardData());
EXPECT_TRUE(Actions::ExecutePasteSelection(m_context, target));
EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforePaste + 1);
const uint64_t pastedEntityId = m_context.GetSelectionManager().GetSelectedEntity();
EXPECT_NE(pastedEntityId, 0u);
EXPECT_NE(pastedEntityId, entity->GetID());
ASSERT_NE(m_context.GetSceneManager().GetEntity(pastedEntityId), nullptr);
EXPECT_EQ(m_context.GetSceneManager().GetEntity(pastedEntityId)->GetParent(), entity);
const Actions::EditActionTarget pastedTarget = Actions::ResolveEditActionTarget(m_context);
ASSERT_NE(pastedTarget.selectedGameObject, nullptr);
const size_t entityCountBeforeDuplicate = CountHierarchyEntities(m_context.GetSceneManager());
EXPECT_TRUE(Actions::ExecuteDuplicateSelection(m_context, pastedTarget));
EXPECT_EQ(CountHierarchyEntities(m_context.GetSceneManager()), entityCountBeforeDuplicate + 1);
const uint64_t duplicatedEntityId = m_context.GetSelectionManager().GetSelectedEntity();
const Actions::EditActionTarget duplicatedTarget = Actions::ResolveEditActionTarget(m_context);
ASSERT_NE(duplicatedTarget.selectedGameObject, nullptr);
EXPECT_EQ(duplicatedTarget.selectedGameObject->GetParent(), entity);
EXPECT_TRUE(Actions::ExecuteDeleteSelection(m_context, duplicatedTarget));
EXPECT_EQ(m_context.GetSceneManager().GetEntity(duplicatedEntityId), nullptr);
EXPECT_FALSE(m_context.GetSelectionManager().IsSelected(duplicatedEntityId));
}
TEST_F(EditorActionRoutingTest, ProjectRouteExecutesOpenBackAndDelete) {
const fs::path assetsDir = m_projectRoot / "Assets";
const fs::path folderPath = assetsDir / "RouteFolder";
const fs::path filePath = assetsDir / "DeleteMe.txt";
fs::create_directories(folderPath);
std::ofstream(filePath.string()) << "temporary";
m_context.GetProjectManager().RefreshCurrentFolder();
const int folderIndex = FindCurrentItemIndexByName("RouteFolder");
ASSERT_GE(folderIndex, 0);
m_context.SetActiveActionRoute(EditorActionRoute::Project);
m_context.GetProjectManager().SetSelectedIndex(folderIndex);
const Actions::EditActionTarget folderTarget = Actions::ResolveEditActionTarget(m_context);
ASSERT_EQ(folderTarget.route, EditorActionRoute::Project);
ASSERT_NE(folderTarget.selectedAssetItem, nullptr);
EXPECT_EQ(folderTarget.selectedAssetItem->name, "RouteFolder");
EXPECT_TRUE(Actions::ExecuteOpenSelection(m_context, folderTarget));
EXPECT_EQ(m_context.GetProjectManager().GetCurrentPath(), "Assets/RouteFolder");
const Actions::EditActionTarget backTarget = Actions::ResolveEditActionTarget(m_context);
EXPECT_TRUE(Actions::ExecuteNavigateBackSelection(m_context, backTarget));
EXPECT_EQ(m_context.GetProjectManager().GetCurrentPath(), "Assets");
m_context.GetProjectManager().RefreshCurrentFolder();
const int fileIndex = FindCurrentItemIndexByName("DeleteMe.txt");
ASSERT_GE(fileIndex, 0);
m_context.GetProjectManager().SetSelectedIndex(fileIndex);
const Actions::EditActionTarget deleteTarget = Actions::ResolveEditActionTarget(m_context);
ASSERT_NE(deleteTarget.selectedAssetItem, nullptr);
EXPECT_TRUE(Actions::ExecuteDeleteSelection(m_context, deleteTarget));
EXPECT_FALSE(fs::exists(filePath));
}
TEST_F(EditorActionRoutingTest, LoadSceneResetsSelectionAndUndoAfterFallbackSave) {
auto* savedEntity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Saved", "SavedEntity");
ASSERT_NE(savedEntity, nullptr);
ASSERT_TRUE(m_context.GetSelectionManager().HasSelection());
ASSERT_TRUE(m_context.GetUndoManager().CanUndo());
const fs::path savedScenePath = m_projectRoot / "Assets" / "Scenes" / "RegressionScene.xc";
EXPECT_TRUE(Commands::SaveDirtySceneWithFallback(m_context, savedScenePath.string()));
EXPECT_TRUE(fs::exists(savedScenePath));
EXPECT_FALSE(m_context.GetSceneManager().IsSceneDirty());
auto* transientEntity = Commands::CreateEmptyEntity(m_context, nullptr, "Create Transient", "TransientEntity");
ASSERT_NE(transientEntity, nullptr);
ASSERT_TRUE(m_context.GetSelectionManager().HasSelection());
ASSERT_TRUE(m_context.GetUndoManager().CanUndo());
EXPECT_TRUE(Commands::LoadScene(m_context, savedScenePath.string(), false));
EXPECT_FALSE(m_context.GetSelectionManager().HasSelection());
EXPECT_FALSE(m_context.GetUndoManager().CanUndo());
ASSERT_EQ(m_context.GetSceneManager().GetRootEntities().size(), 1u);
EXPECT_EQ(m_context.GetSceneManager().GetRootEntities()[0]->GetName(), "SavedEntity");
}
TEST_F(EditorActionRoutingTest, ReparentPreserveWorldTransformKeepsWorldPose) {
auto* parentA = Commands::CreateEmptyEntity(m_context, nullptr, "Create Parent A", "ParentA");
auto* child = Commands::CreateEmptyEntity(m_context, parentA, "Create Child", "Child");
auto* parentB = Commands::CreateEmptyEntity(m_context, nullptr, "Create Parent B", "ParentB");
ASSERT_NE(parentA, nullptr);
ASSERT_NE(child, nullptr);
ASSERT_NE(parentB, nullptr);
parentA->GetTransform()->SetPosition(Math::Vector3(5.0f, 1.0f, -2.0f));
parentB->GetTransform()->SetPosition(Math::Vector3(-4.0f, 3.0f, 8.0f));
child->GetTransform()->SetLocalPosition(Math::Vector3(2.0f, 3.0f, 4.0f));
child->GetTransform()->SetLocalRotation(Math::Quaternion::FromEulerAngles(Math::Vector3(0.25f, 0.5f, 0.75f)));
child->GetTransform()->SetLocalScale(Math::Vector3(1.5f, 2.0f, 0.5f));
const Math::Vector3 worldPositionBefore = child->GetTransform()->GetPosition();
const Math::Vector3 worldScaleBefore = child->GetTransform()->GetScale();
EXPECT_TRUE(Commands::CanReparentEntity(child, parentB));
EXPECT_FALSE(Commands::CanReparentEntity(parentA, child));
EXPECT_TRUE(Commands::ReparentEntityPreserveWorldTransform(m_context, child, parentB->GetID()));
EXPECT_EQ(child->GetParent(), parentB);
ExpectNear(child->GetTransform()->GetPosition(), worldPositionBefore);
ExpectNear(child->GetTransform()->GetScale(), worldScaleBefore);
}
} // namespace
} // namespace XCEngine::Editor