From f912e81ade96ede0b9afd18fd9818133b36187e4 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Mon, 6 Apr 2026 18:05:34 +0800 Subject: [PATCH] feat(xcui): add editor command and menu foundations --- new_editor/CMakeLists.txt | 4 + .../Editor/UIEditorCommandDispatcher.h | 73 ++ .../Editor/UIEditorCommandRegistry.h | 63 ++ .../XCNewEditor/Editor/UIEditorMenuModel.h | 116 +++ .../Editor/UIEditorShortcutManager.h | 100 ++ .../src/editor/UIEditorCommandDispatcher.cpp | 174 ++++ .../src/editor/UIEditorCommandRegistry.cpp | 130 +++ new_editor/src/editor/UIEditorMenuModel.cpp | 265 +++++ .../src/editor/UIEditorShortcutManager.cpp | 344 +++++++ tests/UI/Editor/integration/CMakeLists.txt | 5 +- tests/UI/Editor/integration/README.md | 105 +- .../shared/src/EditorValidationScenario.cpp | 6 +- .../Editor/integration/shell/CMakeLists.txt | 4 + .../shell/menu_bar_basic/CMakeLists.txt | 29 + .../menu_bar_basic}/captures/.gitkeep | 0 .../integration/shell/menu_bar_basic/main.cpp | 935 ++++++++++++++++++ .../workspace_shell_compose/CMakeLists.txt | 0 .../workspace_shell_compose/View.xcui | 0 .../workspace_shell_compose/captures/.gitkeep | 1 + .../workspace_shell_compose/main.cpp | 2 +- .../Editor/integration/state/CMakeLists.txt | 1 + .../state/shortcut_dispatch/CMakeLists.txt | 29 + .../state/shortcut_dispatch/captures/.gitkeep | 1 + .../state/shortcut_dispatch/main.cpp | 641 ++++++++++++ tests/UI/Editor/unit/CMakeLists.txt | 4 + .../test_ui_editor_command_dispatcher.cpp | 118 +++ .../unit/test_ui_editor_command_registry.cpp | 75 ++ .../Editor/unit/test_ui_editor_menu_model.cpp | 223 +++++ .../unit/test_ui_editor_shortcut_manager.cpp | 194 ++++ 29 files changed, 3581 insertions(+), 61 deletions(-) create mode 100644 new_editor/include/XCNewEditor/Editor/UIEditorCommandDispatcher.h create mode 100644 new_editor/include/XCNewEditor/Editor/UIEditorCommandRegistry.h create mode 100644 new_editor/include/XCNewEditor/Editor/UIEditorMenuModel.h create mode 100644 new_editor/include/XCNewEditor/Editor/UIEditorShortcutManager.h create mode 100644 new_editor/src/editor/UIEditorCommandDispatcher.cpp create mode 100644 new_editor/src/editor/UIEditorCommandRegistry.cpp create mode 100644 new_editor/src/editor/UIEditorMenuModel.cpp create mode 100644 new_editor/src/editor/UIEditorShortcutManager.cpp create mode 100644 tests/UI/Editor/integration/shell/CMakeLists.txt create mode 100644 tests/UI/Editor/integration/shell/menu_bar_basic/CMakeLists.txt rename tests/UI/Editor/integration/{workspace_shell_compose => shell/menu_bar_basic}/captures/.gitkeep (100%) create mode 100644 tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp rename tests/UI/Editor/integration/{ => shell}/workspace_shell_compose/CMakeLists.txt (100%) rename tests/UI/Editor/integration/{ => shell}/workspace_shell_compose/View.xcui (100%) create mode 100644 tests/UI/Editor/integration/shell/workspace_shell_compose/captures/.gitkeep rename tests/UI/Editor/integration/{ => shell}/workspace_shell_compose/main.cpp (80%) create mode 100644 tests/UI/Editor/integration/state/shortcut_dispatch/CMakeLists.txt create mode 100644 tests/UI/Editor/integration/state/shortcut_dispatch/captures/.gitkeep create mode 100644 tests/UI/Editor/integration/state/shortcut_dispatch/main.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_command_dispatcher.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_command_registry.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_menu_model.cpp create mode 100644 tests/UI/Editor/unit/test_ui_editor_shortcut_manager.cpp diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index 843c8f45..c13d7326 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -13,7 +13,11 @@ set(NEW_EDITOR_RESOURCE_FILES add_library(XCNewEditorLib STATIC src/editor/EditorShellAsset.cpp + src/editor/UIEditorCommandDispatcher.cpp + src/editor/UIEditorCommandRegistry.cpp + src/editor/UIEditorMenuModel.cpp src/editor/UIEditorPanelRegistry.cpp + src/editor/UIEditorShortcutManager.cpp src/editor/UIEditorWorkspaceLayoutPersistence.cpp src/editor/UIEditorWorkspaceController.cpp src/editor/UIEditorWorkspaceModel.cpp diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorCommandDispatcher.h b/new_editor/include/XCNewEditor/Editor/UIEditorCommandDispatcher.h new file mode 100644 index 00000000..a83471c1 --- /dev/null +++ b/new_editor/include/XCNewEditor/Editor/UIEditorCommandDispatcher.h @@ -0,0 +1,73 @@ +#pragma once + +#include + +#include +#include +#include + +namespace XCEngine::NewEditor { + +enum class UIEditorCommandEvaluationCode : std::uint8_t { + None = 0, + InvalidCommandRegistry, + UnknownCommandId, + MissingActivePanel +}; + +struct UIEditorCommandEvaluationResult { + UIEditorCommandEvaluationCode code = UIEditorCommandEvaluationCode::None; + bool executable = false; + std::string commandId = {}; + std::string displayName = {}; + UIEditorWorkspaceCommand workspaceCommand = {}; + UIEditorWorkspaceCommandResult previewResult = {}; + std::string message = {}; + + [[nodiscard]] bool IsExecutable() const { + return executable; + } +}; + +enum class UIEditorCommandDispatchStatus : std::uint8_t { + Dispatched = 0, + Rejected +}; + +struct UIEditorCommandDispatchResult { + UIEditorCommandDispatchStatus status = UIEditorCommandDispatchStatus::Rejected; + bool commandExecuted = false; + std::string commandId = {}; + std::string displayName = {}; + UIEditorWorkspaceCommand workspaceCommand = {}; + UIEditorWorkspaceCommandResult commandResult = {}; + std::string message = {}; +}; + +std::string_view GetUIEditorCommandDispatchStatusName( + UIEditorCommandDispatchStatus status); + +class UIEditorCommandDispatcher { +public: + UIEditorCommandDispatcher() = default; + explicit UIEditorCommandDispatcher(UIEditorCommandRegistry commandRegistry); + + const UIEditorCommandRegistry& GetCommandRegistry() const { + return m_commandRegistry; + } + + UIEditorCommandRegistryValidationResult ValidateConfiguration() const; + + UIEditorCommandEvaluationResult Evaluate( + std::string_view commandId, + const UIEditorWorkspaceController& controller) const; + + UIEditorCommandDispatchResult Dispatch( + std::string_view commandId, + UIEditorWorkspaceController& controller) const; + +private: + UIEditorCommandRegistry m_commandRegistry = {}; +}; + +} // namespace XCEngine::NewEditor diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorCommandRegistry.h b/new_editor/include/XCNewEditor/Editor/UIEditorCommandRegistry.h new file mode 100644 index 00000000..63c41c4c --- /dev/null +++ b/new_editor/include/XCNewEditor/Editor/UIEditorCommandRegistry.h @@ -0,0 +1,63 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace XCEngine::NewEditor { + +enum class UIEditorCommandPanelSource : std::uint8_t { + None = 0, + FixedPanelId, + ActivePanel +}; + +struct UIEditorWorkspaceCommandDescriptor { + UIEditorWorkspaceCommandKind kind = UIEditorWorkspaceCommandKind::ActivatePanel; + UIEditorCommandPanelSource panelSource = UIEditorCommandPanelSource::FixedPanelId; + std::string panelId = {}; +}; + +struct UIEditorCommandDescriptor { + std::string commandId = {}; + std::string displayName = {}; + UIEditorWorkspaceCommandDescriptor workspaceCommand = {}; +}; + +struct UIEditorCommandRegistry { + std::vector commands = {}; +}; + +enum class UIEditorCommandRegistryValidationCode : std::uint8_t { + None = 0, + EmptyCommandId, + EmptyDisplayName, + DuplicateCommandId, + MissingPanelSource, + MissingFixedPanelId, + UnexpectedPanelSource +}; + +struct UIEditorCommandRegistryValidationResult { + UIEditorCommandRegistryValidationCode code = + UIEditorCommandRegistryValidationCode::None; + std::string message = {}; + + [[nodiscard]] bool IsValid() const { + return code == UIEditorCommandRegistryValidationCode::None; + } +}; + +std::string_view GetUIEditorCommandPanelSourceName(UIEditorCommandPanelSource source); + +const UIEditorCommandDescriptor* FindUIEditorCommandDescriptor( + const UIEditorCommandRegistry& registry, + std::string_view commandId); + +UIEditorCommandRegistryValidationResult ValidateUIEditorCommandRegistry( + const UIEditorCommandRegistry& registry); + +} // namespace XCEngine::NewEditor diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorMenuModel.h b/new_editor/include/XCNewEditor/Editor/UIEditorMenuModel.h new file mode 100644 index 00000000..8bd9241a --- /dev/null +++ b/new_editor/include/XCNewEditor/Editor/UIEditorMenuModel.h @@ -0,0 +1,116 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace XCEngine::NewEditor { + +class UIEditorShortcutManager; + +enum class UIEditorMenuItemKind : std::uint8_t { + Command = 0, + Separator, + Submenu +}; + +enum class UIEditorMenuCheckedStateSource : std::uint8_t { + None = 0, + PanelOpen, + PanelVisible, + PanelActive +}; + +struct UIEditorMenuCheckedStateBinding { + UIEditorMenuCheckedStateSource source = UIEditorMenuCheckedStateSource::None; + std::string panelId = {}; +}; + +struct UIEditorMenuItemDescriptor { + UIEditorMenuItemKind kind = UIEditorMenuItemKind::Command; + std::string itemId = {}; + std::string label = {}; + std::string commandId = {}; + UIEditorMenuCheckedStateBinding checkedState = {}; + std::vector children = {}; +}; + +struct UIEditorMenuDescriptor { + std::string menuId = {}; + std::string label = {}; + std::vector items = {}; +}; + +struct UIEditorMenuModel { + std::vector menus = {}; +}; + +enum class UIEditorMenuModelValidationCode : std::uint8_t { + None = 0, + InvalidCommandRegistry, + EmptyMenuId, + EmptyMenuLabel, + DuplicateMenuId, + EmptyCommandId, + UnknownCommandId, + MissingItemLabel, + CommandItemHasChildren, + SubmenuMissingLabel, + SubmenuEmptyChildren, + SubmenuHasCommandId, + SeparatorHasCommandId, + SeparatorHasChildren, + UnexpectedCheckedState, + MissingCheckedStatePanelId +}; + +struct UIEditorMenuModelValidationResult { + UIEditorMenuModelValidationCode code = UIEditorMenuModelValidationCode::None; + std::string message = {}; + + [[nodiscard]] bool IsValid() const { + return code == UIEditorMenuModelValidationCode::None; + } +}; + +struct UIEditorResolvedMenuItem { + UIEditorMenuItemKind kind = UIEditorMenuItemKind::Command; + std::string itemId = {}; + std::string label = {}; + std::string commandId = {}; + std::string commandDisplayName = {}; + std::string shortcutText = {}; + bool enabled = true; + bool checked = false; + UIEditorWorkspaceCommandStatus previewStatus = + UIEditorWorkspaceCommandStatus::Rejected; + std::string message = {}; + std::vector children = {}; +}; + +struct UIEditorResolvedMenuDescriptor { + std::string menuId = {}; + std::string label = {}; + std::vector items = {}; +}; + +struct UIEditorResolvedMenuModel { + std::vector menus = {}; +}; + +std::string_view GetUIEditorMenuItemKindName(UIEditorMenuItemKind kind); + +UIEditorMenuModelValidationResult ValidateUIEditorMenuModel( + const UIEditorMenuModel& model, + const UIEditorCommandRegistry& commandRegistry); + +UIEditorResolvedMenuModel BuildUIEditorResolvedMenuModel( + const UIEditorMenuModel& model, + const UIEditorCommandDispatcher& commandDispatcher, + const UIEditorWorkspaceController& controller, + const UIEditorShortcutManager* shortcutManager = nullptr); + +} // namespace XCEngine::NewEditor diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorShortcutManager.h b/new_editor/include/XCNewEditor/Editor/UIEditorShortcutManager.h new file mode 100644 index 00000000..a1097d86 --- /dev/null +++ b/new_editor/include/XCNewEditor/Editor/UIEditorShortcutManager.h @@ -0,0 +1,100 @@ +#pragma once + +#include + +#include + +#include +#include +#include + +namespace XCEngine::NewEditor { + +enum class UIEditorShortcutManagerValidationCode : std::uint8_t { + None = 0, + InvalidCommandRegistry, + EmptyBindingCommandId, + UnknownCommandId, + MissingScopedOwnerId, + EmptyShortcutKey, + ConflictingBinding +}; + +struct UIEditorShortcutManagerValidationResult { + UIEditorShortcutManagerValidationCode code = + UIEditorShortcutManagerValidationCode::None; + std::string message = {}; + + [[nodiscard]] bool IsValid() const { + return code == UIEditorShortcutManagerValidationCode::None; + } +}; + +enum class UIEditorShortcutDispatchStatus : std::uint8_t { + NoMatch = 0, + Suppressed, + Dispatched, + Rejected +}; + +struct UIEditorShortcutDispatchResult { + UIEditorShortcutDispatchStatus status = UIEditorShortcutDispatchStatus::NoMatch; + bool matched = false; + bool commandExecuted = false; + std::string commandId = {}; + std::string commandDisplayName = {}; + std::string message = {}; + XCEngine::UI::UIShortcutScope shortcutScope = + XCEngine::UI::UIShortcutScope::Global; + XCEngine::UI::UIElementId shortcutOwnerId = 0; + UIEditorWorkspaceCommandResult commandResult = {}; +}; + +std::string_view GetUIEditorShortcutDispatchStatusName( + UIEditorShortcutDispatchStatus status); + +class UIEditorShortcutManager { +public: + UIEditorShortcutManager() = default; + explicit UIEditorShortcutManager(UIEditorCommandRegistry commandRegistry); + + const UIEditorCommandDispatcher& GetCommandDispatcher() const { + return m_commandDispatcher; + } + + const UIEditorCommandRegistry& GetCommandRegistry() const { + return m_commandDispatcher.GetCommandRegistry(); + } + + const XCEngine::UI::UIShortcutRegistry& GetShortcutRegistry() const { + return m_shortcutRegistry; + } + + std::uint64_t RegisterBinding(const XCEngine::UI::UIShortcutBinding& binding); + bool UnregisterBinding(std::uint64_t bindingId); + void ClearBindings(); + + UIEditorShortcutManagerValidationResult ValidateConfiguration() const; + std::string GetPreferredShortcutText(std::string_view commandId) const; + + UIEditorShortcutDispatchResult Dispatch( + const XCEngine::UI::UIInputEvent& event, + const XCEngine::UI::UIShortcutContext& shortcutContext, + UIEditorWorkspaceController& controller) const; + +private: + UIEditorShortcutDispatchResult BuildDispatchResult( + UIEditorShortcutDispatchStatus status, + std::string commandId, + std::string commandDisplayName, + std::string message, + const XCEngine::UI::UIShortcutMatch* match = nullptr) const; + + const XCEngine::UI::UIShortcutBinding* FindPreferredBinding( + std::string_view commandId) const; + + UIEditorCommandDispatcher m_commandDispatcher = {}; + XCEngine::UI::UIShortcutRegistry m_shortcutRegistry = {}; +}; + +} // namespace XCEngine::NewEditor diff --git a/new_editor/src/editor/UIEditorCommandDispatcher.cpp b/new_editor/src/editor/UIEditorCommandDispatcher.cpp new file mode 100644 index 00000000..8e86a33a --- /dev/null +++ b/new_editor/src/editor/UIEditorCommandDispatcher.cpp @@ -0,0 +1,174 @@ +#include + +#include + +namespace XCEngine::NewEditor { + +namespace { + +UIEditorCommandEvaluationResult MakeEvaluationResult( + UIEditorCommandEvaluationCode code, + bool executable, + std::string commandId, + std::string displayName, + UIEditorWorkspaceCommand workspaceCommand, + UIEditorWorkspaceCommandResult previewResult, + std::string message) { + UIEditorCommandEvaluationResult result = {}; + result.code = code; + result.executable = executable; + result.commandId = std::move(commandId); + result.displayName = std::move(displayName); + result.workspaceCommand = std::move(workspaceCommand); + result.previewResult = std::move(previewResult); + result.message = std::move(message); + return result; +} + +UIEditorCommandDispatchResult BuildDispatchResult( + UIEditorCommandDispatchStatus status, + bool commandExecuted, + std::string commandId, + std::string displayName, + UIEditorWorkspaceCommand workspaceCommand, + UIEditorWorkspaceCommandResult commandResult, + std::string message) { + UIEditorCommandDispatchResult result = {}; + result.status = status; + result.commandExecuted = commandExecuted; + result.commandId = std::move(commandId); + result.displayName = std::move(displayName); + result.workspaceCommand = std::move(workspaceCommand); + result.commandResult = std::move(commandResult); + result.message = std::move(message); + return result; +} + +} // namespace + +std::string_view GetUIEditorCommandDispatchStatusName( + UIEditorCommandDispatchStatus status) { + switch (status) { + case UIEditorCommandDispatchStatus::Dispatched: + return "Dispatched"; + case UIEditorCommandDispatchStatus::Rejected: + return "Rejected"; + } + + return "Unknown"; +} + +UIEditorCommandDispatcher::UIEditorCommandDispatcher( + UIEditorCommandRegistry commandRegistry) + : m_commandRegistry(std::move(commandRegistry)) { +} + +UIEditorCommandRegistryValidationResult +UIEditorCommandDispatcher::ValidateConfiguration() const { + return ValidateUIEditorCommandRegistry(m_commandRegistry); +} + +UIEditorCommandEvaluationResult UIEditorCommandDispatcher::Evaluate( + std::string_view commandId, + const UIEditorWorkspaceController& controller) const { + const auto validation = ValidateConfiguration(); + if (!validation.IsValid()) { + return MakeEvaluationResult( + UIEditorCommandEvaluationCode::InvalidCommandRegistry, + false, + std::string(commandId), + {}, + {}, + {}, + "Command registry invalid: " + validation.message); + } + + const UIEditorCommandDescriptor* descriptor = + FindUIEditorCommandDescriptor(m_commandRegistry, commandId); + if (descriptor == nullptr) { + return MakeEvaluationResult( + UIEditorCommandEvaluationCode::UnknownCommandId, + false, + std::string(commandId), + {}, + {}, + {}, + "Editor command '" + std::string(commandId) + "' is not registered."); + } + + UIEditorWorkspaceCommand workspaceCommand = {}; + workspaceCommand.kind = descriptor->workspaceCommand.kind; + + switch (descriptor->workspaceCommand.panelSource) { + case UIEditorCommandPanelSource::None: + break; + + case UIEditorCommandPanelSource::FixedPanelId: + workspaceCommand.panelId = descriptor->workspaceCommand.panelId; + break; + + case UIEditorCommandPanelSource::ActivePanel: + if (controller.GetWorkspace().activePanelId.empty()) { + return MakeEvaluationResult( + UIEditorCommandEvaluationCode::MissingActivePanel, + false, + descriptor->commandId, + descriptor->displayName, + {}, + {}, + "Editor command '" + descriptor->commandId + "' requires an active panel."); + } + workspaceCommand.panelId = controller.GetWorkspace().activePanelId; + break; + } + + UIEditorWorkspaceController previewController = controller; + UIEditorWorkspaceCommandResult previewResult = + previewController.Dispatch(workspaceCommand); + + return MakeEvaluationResult( + UIEditorCommandEvaluationCode::None, + previewResult.status != UIEditorWorkspaceCommandStatus::Rejected, + descriptor->commandId, + descriptor->displayName, + std::move(workspaceCommand), + std::move(previewResult), + "Editor command resolved."); +} + +UIEditorCommandDispatchResult UIEditorCommandDispatcher::Dispatch( + std::string_view commandId, + UIEditorWorkspaceController& controller) const { + const UIEditorCommandEvaluationResult evaluation = + Evaluate(commandId, controller); + if (!evaluation.IsExecutable()) { + return BuildDispatchResult( + UIEditorCommandDispatchStatus::Rejected, + false, + evaluation.commandId, + evaluation.displayName, + evaluation.workspaceCommand, + evaluation.previewResult, + evaluation.message); + } + + UIEditorWorkspaceCommandResult commandResult = + controller.Dispatch(evaluation.workspaceCommand); + const bool commandExecuted = + commandResult.status != UIEditorWorkspaceCommandStatus::Rejected; + + return BuildDispatchResult( + commandExecuted + ? UIEditorCommandDispatchStatus::Dispatched + : UIEditorCommandDispatchStatus::Rejected, + commandExecuted, + evaluation.commandId, + evaluation.displayName, + evaluation.workspaceCommand, + std::move(commandResult), + commandExecuted + ? "Editor command dispatched." + : "Editor command dispatch was rejected."); +} + +} // namespace XCEngine::NewEditor diff --git a/new_editor/src/editor/UIEditorCommandRegistry.cpp b/new_editor/src/editor/UIEditorCommandRegistry.cpp new file mode 100644 index 00000000..3e0ccffa --- /dev/null +++ b/new_editor/src/editor/UIEditorCommandRegistry.cpp @@ -0,0 +1,130 @@ +#include + +#include +#include + +namespace XCEngine::NewEditor { + +namespace { + +UIEditorCommandRegistryValidationResult MakeValidationError( + UIEditorCommandRegistryValidationCode code, + std::string message) { + UIEditorCommandRegistryValidationResult result = {}; + result.code = code; + result.message = std::move(message); + return result; +} + +bool CommandKindRequiresPanelId(UIEditorWorkspaceCommandKind kind) { + switch (kind) { + case UIEditorWorkspaceCommandKind::OpenPanel: + case UIEditorWorkspaceCommandKind::ClosePanel: + case UIEditorWorkspaceCommandKind::ShowPanel: + case UIEditorWorkspaceCommandKind::HidePanel: + case UIEditorWorkspaceCommandKind::ActivatePanel: + return true; + case UIEditorWorkspaceCommandKind::ResetWorkspace: + return false; + } + + return false; +} + +} // namespace + +std::string_view GetUIEditorCommandPanelSourceName(UIEditorCommandPanelSource source) { + switch (source) { + case UIEditorCommandPanelSource::None: + return "None"; + case UIEditorCommandPanelSource::FixedPanelId: + return "FixedPanelId"; + case UIEditorCommandPanelSource::ActivePanel: + return "ActivePanel"; + } + + return "Unknown"; +} + +const UIEditorCommandDescriptor* FindUIEditorCommandDescriptor( + const UIEditorCommandRegistry& registry, + std::string_view commandId) { + for (const UIEditorCommandDescriptor& command : registry.commands) { + if (command.commandId == commandId) { + return &command; + } + } + + return nullptr; +} + +UIEditorCommandRegistryValidationResult ValidateUIEditorCommandRegistry( + const UIEditorCommandRegistry& registry) { + std::unordered_set seenCommandIds = {}; + for (const UIEditorCommandDescriptor& command : registry.commands) { + if (command.commandId.empty()) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::EmptyCommandId, + "Editor command id must not be empty."); + } + + if (command.displayName.empty()) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::EmptyDisplayName, + "Editor command '" + command.commandId + "' must define a displayName."); + } + + if (!seenCommandIds.insert(command.commandId).second) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::DuplicateCommandId, + "Editor command id '" + command.commandId + "' is duplicated."); + } + + const bool requiresPanelId = + CommandKindRequiresPanelId(command.workspaceCommand.kind); + switch (command.workspaceCommand.panelSource) { + case UIEditorCommandPanelSource::None: + if (requiresPanelId) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::MissingPanelSource, + "Editor command '" + command.commandId + "' requires a panel source."); + } + if (!command.workspaceCommand.panelId.empty()) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::UnexpectedPanelSource, + "Editor command '" + command.commandId + "' must not carry a fixed panel id."); + } + break; + + case UIEditorCommandPanelSource::FixedPanelId: + if (!requiresPanelId) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::UnexpectedPanelSource, + "Editor command '" + command.commandId + "' must not use a fixed panel id."); + } + if (command.workspaceCommand.panelId.empty()) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::MissingFixedPanelId, + "Editor command '" + command.commandId + "' fixed panel source requires a panel id."); + } + break; + + case UIEditorCommandPanelSource::ActivePanel: + if (!requiresPanelId) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::UnexpectedPanelSource, + "Editor command '" + command.commandId + "' must not use the active panel source."); + } + if (!command.workspaceCommand.panelId.empty()) { + return MakeValidationError( + UIEditorCommandRegistryValidationCode::UnexpectedPanelSource, + "Editor command '" + command.commandId + "' active panel source must not carry a fixed panel id."); + } + break; + } + } + + return {}; +} + +} // namespace XCEngine::NewEditor diff --git a/new_editor/src/editor/UIEditorMenuModel.cpp b/new_editor/src/editor/UIEditorMenuModel.cpp new file mode 100644 index 00000000..292a9a36 --- /dev/null +++ b/new_editor/src/editor/UIEditorMenuModel.cpp @@ -0,0 +1,265 @@ +#include + +#include + +#include +#include + +namespace XCEngine::NewEditor { + +namespace { + +UIEditorMenuModelValidationResult MakeValidationError( + UIEditorMenuModelValidationCode code, + std::string message) { + UIEditorMenuModelValidationResult result = {}; + result.code = code; + result.message = std::move(message); + return result; +} + +bool ResolveCheckedState( + const UIEditorMenuCheckedStateBinding& binding, + const UIEditorWorkspaceController& controller) { + if (binding.source == UIEditorMenuCheckedStateSource::None) { + return false; + } + + const UIEditorPanelSessionState* panelState = + FindUIEditorPanelSessionState(controller.GetSession(), binding.panelId); + if (panelState == nullptr) { + return false; + } + + switch (binding.source) { + case UIEditorMenuCheckedStateSource::PanelOpen: + return panelState->open; + case UIEditorMenuCheckedStateSource::PanelVisible: + return panelState->visible; + case UIEditorMenuCheckedStateSource::PanelActive: + return controller.GetWorkspace().activePanelId == binding.panelId; + case UIEditorMenuCheckedStateSource::None: + break; + } + + return false; +} + +UIEditorMenuModelValidationResult ValidateMenuItems( + const std::vector& items, + const UIEditorCommandRegistry& commandRegistry, + std::string_view parentPath) { + for (std::size_t index = 0; index < items.size(); ++index) { + const UIEditorMenuItemDescriptor& item = items[index]; + const std::string itemPath = + std::string(parentPath) + "[" + std::to_string(index) + "]"; + + switch (item.kind) { + case UIEditorMenuItemKind::Command: + if (item.commandId.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::EmptyCommandId, + "Menu item '" + itemPath + "' must define a commandId."); + } + if (FindUIEditorCommandDescriptor(commandRegistry, item.commandId) == nullptr) { + return MakeValidationError( + UIEditorMenuModelValidationCode::UnknownCommandId, + "Menu item '" + itemPath + "' references unknown command '" + + item.commandId + "'."); + } + if (item.label.empty()) { + const UIEditorCommandDescriptor* descriptor = + FindUIEditorCommandDescriptor(commandRegistry, item.commandId); + if (descriptor == nullptr || descriptor->displayName.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::MissingItemLabel, + "Menu item '" + itemPath + "' must define a label or use a command with displayName."); + } + } + if (!item.children.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::CommandItemHasChildren, + "Command menu item '" + itemPath + "' must not define children."); + } + if (item.checkedState.source != UIEditorMenuCheckedStateSource::None && + item.checkedState.panelId.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::MissingCheckedStatePanelId, + "Command menu item '" + itemPath + "' checked state requires a panelId."); + } + break; + + case UIEditorMenuItemKind::Separator: + if (!item.commandId.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::SeparatorHasCommandId, + "Separator menu item '" + itemPath + "' must not define a commandId."); + } + if (!item.children.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::SeparatorHasChildren, + "Separator menu item '" + itemPath + "' must not define children."); + } + if (item.checkedState.source != UIEditorMenuCheckedStateSource::None) { + return MakeValidationError( + UIEditorMenuModelValidationCode::UnexpectedCheckedState, + "Separator menu item '" + itemPath + "' must not define checked state."); + } + break; + + case UIEditorMenuItemKind::Submenu: + if (item.label.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::SubmenuMissingLabel, + "Submenu item '" + itemPath + "' must define a label."); + } + if (item.children.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::SubmenuEmptyChildren, + "Submenu item '" + itemPath + "' must contain at least one child."); + } + if (!item.commandId.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::SubmenuHasCommandId, + "Submenu item '" + itemPath + "' must not define a commandId."); + } + if (item.checkedState.source != UIEditorMenuCheckedStateSource::None) { + return MakeValidationError( + UIEditorMenuModelValidationCode::UnexpectedCheckedState, + "Submenu item '" + itemPath + "' must not define checked state."); + } + { + const auto childValidation = + ValidateMenuItems(item.children, commandRegistry, itemPath); + if (!childValidation.IsValid()) { + return childValidation; + } + } + break; + } + } + + return {}; +} + +UIEditorResolvedMenuItem ResolveMenuItem( + const UIEditorMenuItemDescriptor& item, + const UIEditorCommandDispatcher& commandDispatcher, + const UIEditorWorkspaceController& controller, + const UIEditorShortcutManager* shortcutManager) { + UIEditorResolvedMenuItem resolved = {}; + resolved.kind = item.kind; + resolved.itemId = item.itemId; + resolved.label = item.label; + resolved.commandId = item.commandId; + + switch (item.kind) { + case UIEditorMenuItemKind::Separator: + resolved.enabled = false; + return resolved; + + case UIEditorMenuItemKind::Submenu: + for (const UIEditorMenuItemDescriptor& child : item.children) { + resolved.children.push_back( + ResolveMenuItem(child, commandDispatcher, controller, shortcutManager)); + } + resolved.enabled = !resolved.children.empty(); + return resolved; + + case UIEditorMenuItemKind::Command: + break; + } + + const UIEditorCommandEvaluationResult evaluation = + commandDispatcher.Evaluate(item.commandId, controller); + resolved.commandDisplayName = evaluation.displayName; + if (resolved.label.empty()) { + resolved.label = evaluation.displayName; + } + resolved.shortcutText = + shortcutManager != nullptr + ? shortcutManager->GetPreferredShortcutText(item.commandId) + : std::string(); + resolved.enabled = evaluation.IsExecutable(); + resolved.previewStatus = evaluation.previewResult.status; + resolved.message = evaluation.message; + resolved.checked = ResolveCheckedState(item.checkedState, controller); + return resolved; +} + +} // namespace + +std::string_view GetUIEditorMenuItemKindName(UIEditorMenuItemKind kind) { + switch (kind) { + case UIEditorMenuItemKind::Command: + return "Command"; + case UIEditorMenuItemKind::Separator: + return "Separator"; + case UIEditorMenuItemKind::Submenu: + return "Submenu"; + } + + return "Unknown"; +} + +UIEditorMenuModelValidationResult ValidateUIEditorMenuModel( + const UIEditorMenuModel& model, + const UIEditorCommandRegistry& commandRegistry) { + const auto commandValidation = ValidateUIEditorCommandRegistry(commandRegistry); + if (!commandValidation.IsValid()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::InvalidCommandRegistry, + commandValidation.message); + } + + std::unordered_set seenMenuIds = {}; + for (std::size_t index = 0; index < model.menus.size(); ++index) { + const UIEditorMenuDescriptor& menu = model.menus[index]; + const std::string menuPath = "menus[" + std::to_string(index) + "]"; + if (menu.menuId.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::EmptyMenuId, + menuPath + " must define menuId."); + } + if (menu.label.empty()) { + return MakeValidationError( + UIEditorMenuModelValidationCode::EmptyMenuLabel, + menuPath + " must define label."); + } + if (!seenMenuIds.insert(menu.menuId).second) { + return MakeValidationError( + UIEditorMenuModelValidationCode::DuplicateMenuId, + "Duplicate menuId '" + menu.menuId + "'."); + } + + const auto itemValidation = + ValidateMenuItems(menu.items, commandRegistry, menuPath + ".items"); + if (!itemValidation.IsValid()) { + return itemValidation; + } + } + + return {}; +} + +UIEditorResolvedMenuModel BuildUIEditorResolvedMenuModel( + const UIEditorMenuModel& model, + const UIEditorCommandDispatcher& commandDispatcher, + const UIEditorWorkspaceController& controller, + const UIEditorShortcutManager* shortcutManager) { + UIEditorResolvedMenuModel resolved = {}; + for (const UIEditorMenuDescriptor& menu : model.menus) { + UIEditorResolvedMenuDescriptor resolvedMenu = {}; + resolvedMenu.menuId = menu.menuId; + resolvedMenu.label = menu.label; + for (const UIEditorMenuItemDescriptor& item : menu.items) { + resolvedMenu.items.push_back( + ResolveMenuItem(item, commandDispatcher, controller, shortcutManager)); + } + resolved.menus.push_back(std::move(resolvedMenu)); + } + + return resolved; +} + +} // namespace XCEngine::NewEditor diff --git a/new_editor/src/editor/UIEditorShortcutManager.cpp b/new_editor/src/editor/UIEditorShortcutManager.cpp new file mode 100644 index 00000000..fc276f2a --- /dev/null +++ b/new_editor/src/editor/UIEditorShortcutManager.cpp @@ -0,0 +1,344 @@ +#include + +#include + +#include + +namespace XCEngine::NewEditor { + +namespace { + +using XCEngine::UI::UIShortcutBinding; +using XCEngine::UI::UIShortcutMatch; +using XCEngine::UI::UIShortcutScope; +using XCEngine::Input::KeyCode; + +UIEditorShortcutManagerValidationResult MakeValidationError( + UIEditorShortcutManagerValidationCode code, + std::string message) { + UIEditorShortcutManagerValidationResult result = {}; + result.code = code; + result.message = std::move(message); + return result; +} + +bool ModifiersEqual( + const XCEngine::UI::UIInputModifiers& lhs, + const XCEngine::UI::UIInputModifiers& rhs) { + return lhs.shift == rhs.shift && + lhs.control == rhs.control && + lhs.alt == rhs.alt && + lhs.super == rhs.super; +} + +bool HaveConflictingChord( + const UIShortcutBinding& lhs, + const UIShortcutBinding& rhs) { + return lhs.triggerEventType == rhs.triggerEventType && + lhs.scope == rhs.scope && + lhs.ownerId == rhs.ownerId && + lhs.chord.keyCode == rhs.chord.keyCode && + ModifiersEqual(lhs.chord.modifiers, rhs.chord.modifiers); +} + +int ShortcutDisplayPriority(UIShortcutScope scope) { + switch (scope) { + case UIShortcutScope::Global: + return 0; + case UIShortcutScope::Window: + return 1; + case UIShortcutScope::Panel: + return 2; + case UIShortcutScope::Widget: + return 3; + } + + return 4; +} + +std::string GetKeyCodeDisplayName(std::int32_t keyCode) { + if (keyCode >= static_cast(KeyCode::A) && + keyCode <= static_cast(KeyCode::Z)) { + return std::string(1, static_cast('A' + + (keyCode - static_cast(KeyCode::A)))); + } + + if (keyCode == static_cast(KeyCode::Zero)) return "0"; + if (keyCode == static_cast(KeyCode::One)) return "1"; + if (keyCode == static_cast(KeyCode::Two)) return "2"; + if (keyCode == static_cast(KeyCode::Three)) return "3"; + if (keyCode == static_cast(KeyCode::Four)) return "4"; + if (keyCode == static_cast(KeyCode::Five)) return "5"; + if (keyCode == static_cast(KeyCode::Six)) return "6"; + if (keyCode == static_cast(KeyCode::Seven)) return "7"; + if (keyCode == static_cast(KeyCode::Eight)) return "8"; + if (keyCode == static_cast(KeyCode::Nine)) return "9"; + + if (keyCode == static_cast(KeyCode::F1)) return "F1"; + if (keyCode == static_cast(KeyCode::F2)) return "F2"; + if (keyCode == static_cast(KeyCode::F3)) return "F3"; + if (keyCode == static_cast(KeyCode::F4)) return "F4"; + if (keyCode == static_cast(KeyCode::F5)) return "F5"; + if (keyCode == static_cast(KeyCode::F6)) return "F6"; + if (keyCode == static_cast(KeyCode::F7)) return "F7"; + if (keyCode == static_cast(KeyCode::F8)) return "F8"; + if (keyCode == static_cast(KeyCode::F9)) return "F9"; + if (keyCode == static_cast(KeyCode::F10)) return "F10"; + if (keyCode == static_cast(KeyCode::F11)) return "F11"; + if (keyCode == static_cast(KeyCode::F12)) return "F12"; + + if (keyCode == static_cast(KeyCode::Tab)) return "Tab"; + if (keyCode == static_cast(KeyCode::Enter)) return "Enter"; + if (keyCode == static_cast(KeyCode::Escape)) return "Esc"; + if (keyCode == static_cast(KeyCode::Space)) return "Space"; + if (keyCode == static_cast(KeyCode::Delete)) return "Delete"; + if (keyCode == static_cast(KeyCode::Backspace)) return "Backspace"; + if (keyCode == static_cast(KeyCode::Up)) return "Up"; + if (keyCode == static_cast(KeyCode::Down)) return "Down"; + if (keyCode == static_cast(KeyCode::Left)) return "Left"; + if (keyCode == static_cast(KeyCode::Right)) return "Right"; + if (keyCode == static_cast(KeyCode::Home)) return "Home"; + if (keyCode == static_cast(KeyCode::End)) return "End"; + if (keyCode == static_cast(KeyCode::PageUp)) return "PageUp"; + if (keyCode == static_cast(KeyCode::PageDown)) return "PageDown"; + + if (keyCode == static_cast(KeyCode::Minus)) return "-"; + if (keyCode == static_cast(KeyCode::Equals)) return "="; + if (keyCode == static_cast(KeyCode::BracketLeft)) return "["; + if (keyCode == static_cast(KeyCode::Semicolon)) return ";"; + if (keyCode == static_cast(KeyCode::Period)) return "."; + if (keyCode == static_cast(KeyCode::Slash)) return "/"; + if (keyCode == static_cast(KeyCode::Backslash)) return "\\"; + if (keyCode == static_cast(KeyCode::Backtick)) return "`"; + + return keyCode == 0 + ? std::string() + : "Key(" + std::to_string(keyCode) + ")"; +} + +std::string FormatShortcutChord(const XCEngine::UI::UIShortcutChord& chord) { + std::string result = {}; + if (chord.modifiers.control) { + result += "Ctrl+"; + } + if (chord.modifiers.shift) { + result += "Shift+"; + } + if (chord.modifiers.alt) { + result += "Alt+"; + } + if (chord.modifiers.super) { + result += "Super+"; + } + + result += GetKeyCodeDisplayName(chord.keyCode); + return result; +} + +} // namespace + +std::string_view GetUIEditorShortcutDispatchStatusName( + UIEditorShortcutDispatchStatus status) { + switch (status) { + case UIEditorShortcutDispatchStatus::NoMatch: + return "NoMatch"; + case UIEditorShortcutDispatchStatus::Suppressed: + return "Suppressed"; + case UIEditorShortcutDispatchStatus::Dispatched: + return "Dispatched"; + case UIEditorShortcutDispatchStatus::Rejected: + return "Rejected"; + } + + return "Unknown"; +} + +UIEditorShortcutManager::UIEditorShortcutManager(UIEditorCommandRegistry commandRegistry) + : m_commandDispatcher(std::move(commandRegistry)) { +} + +std::uint64_t UIEditorShortcutManager::RegisterBinding( + const XCEngine::UI::UIShortcutBinding& binding) { + return m_shortcutRegistry.RegisterBinding(binding); +} + +bool UIEditorShortcutManager::UnregisterBinding(std::uint64_t bindingId) { + return m_shortcutRegistry.UnregisterBinding(bindingId); +} + +void UIEditorShortcutManager::ClearBindings() { + m_shortcutRegistry.Clear(); +} + +UIEditorShortcutManagerValidationResult UIEditorShortcutManager::ValidateConfiguration() const { + const UIEditorCommandRegistryValidationResult commandValidation = + m_commandDispatcher.ValidateConfiguration(); + if (!commandValidation.IsValid()) { + return MakeValidationError( + UIEditorShortcutManagerValidationCode::InvalidCommandRegistry, + commandValidation.message); + } + + const std::vector& bindings = m_shortcutRegistry.GetBindings(); + for (std::size_t index = 0; index < bindings.size(); ++index) { + const UIShortcutBinding& binding = bindings[index]; + if (binding.commandId.empty()) { + return MakeValidationError( + UIEditorShortcutManagerValidationCode::EmptyBindingCommandId, + "Editor shortcut binding commandId must not be empty."); + } + + if (FindUIEditorCommandDescriptor( + m_commandDispatcher.GetCommandRegistry(), + binding.commandId) == nullptr) { + return MakeValidationError( + UIEditorShortcutManagerValidationCode::UnknownCommandId, + "Editor shortcut binding references unknown command '" + binding.commandId + "'."); + } + + if (binding.chord.keyCode == 0) { + return MakeValidationError( + UIEditorShortcutManagerValidationCode::EmptyShortcutKey, + "Editor shortcut binding '" + binding.commandId + "' must define a keyCode."); + } + + if (binding.scope != UIShortcutScope::Global && binding.ownerId == 0) { + return MakeValidationError( + UIEditorShortcutManagerValidationCode::MissingScopedOwnerId, + "Editor shortcut binding '" + binding.commandId + "' must define ownerId for non-global scope."); + } + + for (std::size_t candidateIndex = index + 1u; candidateIndex < bindings.size(); ++candidateIndex) { + const UIShortcutBinding& candidate = bindings[candidateIndex]; + if (HaveConflictingChord(binding, candidate)) { + return MakeValidationError( + UIEditorShortcutManagerValidationCode::ConflictingBinding, + "Editor shortcut bindings '" + binding.commandId + + "' and '" + candidate.commandId + + "' conflict on the same chord/scope/owner."); + } + } + } + + return {}; +} + +const XCEngine::UI::UIShortcutBinding* UIEditorShortcutManager::FindPreferredBinding( + std::string_view commandId) const { + const UIShortcutBinding* preferred = nullptr; + for (const UIShortcutBinding& binding : m_shortcutRegistry.GetBindings()) { + if (binding.commandId != commandId) { + continue; + } + + if (preferred == nullptr) { + preferred = &binding; + continue; + } + + const int currentPriority = ShortcutDisplayPriority(binding.scope); + const int preferredPriority = ShortcutDisplayPriority(preferred->scope); + if (currentPriority < preferredPriority || + (currentPriority == preferredPriority && + (binding.ownerId < preferred->ownerId || + (binding.ownerId == preferred->ownerId && + binding.bindingId < preferred->bindingId)))) { + preferred = &binding; + } + } + + return preferred; +} + +std::string UIEditorShortcutManager::GetPreferredShortcutText( + std::string_view commandId) const { + const UIShortcutBinding* binding = FindPreferredBinding(commandId); + return binding != nullptr ? FormatShortcutChord(binding->chord) : std::string(); +} + +UIEditorShortcutDispatchResult UIEditorShortcutManager::BuildDispatchResult( + UIEditorShortcutDispatchStatus status, + std::string commandId, + std::string commandDisplayName, + std::string message, + const UIShortcutMatch* match) const { + UIEditorShortcutDispatchResult result = {}; + result.status = status; + result.matched = match != nullptr && match->matched; + result.commandId = std::move(commandId); + result.commandDisplayName = std::move(commandDisplayName); + result.message = std::move(message); + if (match != nullptr && match->matched) { + result.shortcutScope = match->binding.scope; + result.shortcutOwnerId = match->binding.ownerId; + } + return result; +} + +UIEditorShortcutDispatchResult UIEditorShortcutManager::Dispatch( + const XCEngine::UI::UIInputEvent& event, + const XCEngine::UI::UIShortcutContext& shortcutContext, + UIEditorWorkspaceController& controller) const { + const UIEditorShortcutManagerValidationResult validation = ValidateConfiguration(); + if (!validation.IsValid()) { + return BuildDispatchResult( + UIEditorShortcutDispatchStatus::Rejected, + {}, + {}, + "Shortcut manager configuration invalid: " + validation.message); + } + + const UIShortcutMatch match = m_shortcutRegistry.Match(event, shortcutContext); + if (!match.matched) { + return BuildDispatchResult( + UIEditorShortcutDispatchStatus::NoMatch, + {}, + {}, + "No shortcut binding matched the input event."); + } + + const UIEditorCommandDescriptor* descriptor = + FindUIEditorCommandDescriptor( + m_commandDispatcher.GetCommandRegistry(), + match.binding.commandId); + if (descriptor == nullptr) { + return BuildDispatchResult( + UIEditorShortcutDispatchStatus::Rejected, + match.binding.commandId, + {}, + "Matched shortcut references an unknown editor command.", + &match); + } + + if (shortcutContext.textInputActive) { + return BuildDispatchResult( + UIEditorShortcutDispatchStatus::Suppressed, + descriptor->commandId, + descriptor->displayName, + "Shortcut matched but was suppressed by active text input.", + &match); + } + + const UIEditorCommandDispatchResult dispatchResult = + m_commandDispatcher.Dispatch(descriptor->commandId, controller); + if (!dispatchResult.commandExecuted) { + return BuildDispatchResult( + UIEditorShortcutDispatchStatus::Rejected, + descriptor->commandId, + descriptor->displayName, + dispatchResult.message, + &match); + } + + UIEditorShortcutDispatchResult result = BuildDispatchResult( + UIEditorShortcutDispatchStatus::Dispatched, + descriptor->commandId, + descriptor->displayName, + "Shortcut matched and command dispatched.", + &match); + result.commandExecuted = true; + result.commandResult = dispatchResult.commandResult; + return result; +} + +} // namespace XCEngine::NewEditor diff --git a/tests/UI/Editor/integration/CMakeLists.txt b/tests/UI/Editor/integration/CMakeLists.txt index edf07f60..a0ed9c94 100644 --- a/tests/UI/Editor/integration/CMakeLists.txt +++ b/tests/UI/Editor/integration/CMakeLists.txt @@ -1,11 +1,14 @@ file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH) add_subdirectory(shared) -add_subdirectory(workspace_shell_compose) +add_subdirectory(shell) add_subdirectory(state) add_custom_target(editor_ui_integration_tests DEPENDS editor_ui_workspace_shell_compose_validation + editor_ui_menu_bar_basic_validation editor_ui_panel_session_flow_validation + editor_ui_layout_persistence_validation + editor_ui_shortcut_dispatch_validation ) diff --git a/tests/UI/Editor/integration/README.md b/tests/UI/Editor/integration/README.md index af580e97..493525ae 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -2,75 +2,68 @@ This directory contains editor-only XCUI manual validation scenarios. -Current status: +Rules: -- Shared Core primitives remain in `tests/UI/Core/integration/`. -- Only editor-only host, shell, widget, and domain-integrated validation should live here. -- The first authored scenario is `workspace_shell_compose/`, focused on shell compose only: - splitters, tab host, panel chrome placeholders, and hot reload. -- The second scenario is `state/panel_session_flow/`, focused on editor command dispatch and panel session state only: - `command dispatch + workspace controller + open / close / show / hide / activate`. -- The third scenario is `state/layout_persistence/`, focused on editor layout save/load/reset only: - `layout snapshot + serialize / deserialize + invalid payload reject`. +- Shared Core primitives stay in `tests/UI/Core/integration/`. +- Only editor-only shell, host, widget, and domain-integrated validation belongs here. +- Scenarios are organized as `tests/UI/Editor/integration///`. +- Each scenario owns its own `captures/` directory. Layout: -- `shared/`: editor validation scenario registry, Win32 host wrapper, shared theme -- `workspace_shell_compose/`: first manual editor shell compose scenario -- `state/panel_session_flow/`: custom host scenario for editor panel session state flow -- `state/layout_persistence/`: custom host scenario for editor layout persistence flow +- `shared/`: shared host wrapper, scenario registry, shared theme +- `shell/workspace_shell_compose/`: split/tab/panel shell compose only +- `shell/menu_bar_basic/`: menu bar open/close/hover/dispatch only +- `state/panel_session_flow/`: panel session state flow only +- `state/layout_persistence/`: layout save/load/reset only +- `state/shortcut_dispatch/`: shortcut match/suppression/dispatch only -Current scenario: +Scenarios: -- Scenario id: `editor.shell.workspace_compose` -- Build target: `editor_ui_workspace_shell_compose_validation` -- Executable name: `XCUIEditorWorkspaceShellComposeValidation` -- Validation scope: split/tab/panel shell compose only, no business panels +- `editor.shell.workspace_shell_compose` + Build target: `editor_ui_workspace_shell_compose_validation` + Executable: `XCUIEditorWorkspaceShellComposeValidation.exe` + Scope: splitters, tab host, panel chrome placeholders, hot reload -Additional scenario: +- `editor.shell.menu_bar_basic` + Build target: `editor_ui_menu_bar_basic_validation` + Executable: `XCUIEditorMenuBarBasicValidation.exe` + Scope: menu bar open/close, hover, dismiss, menu command dispatch only -- Scenario id: `editor.state.panel_session_flow` -- Build target: `editor_ui_panel_session_flow_validation` -- Executable name: `XCUIEditorPanelSessionFlowValidation` -- Validation scope: editor command dispatch and panel session state only, no business panels +- `editor.state.panel_session_flow` + Build target: `editor_ui_panel_session_flow_validation` + Executable: `XCUIEditorPanelSessionFlowValidation.exe` + Scope: command dispatch + workspace controller + open/close/show/hide/activate -Additional scenario: +- `editor.state.layout_persistence` + Build target: `editor_ui_layout_persistence_validation` + Executable: `XCUIEditorLayoutPersistenceValidation.exe` + Scope: layout snapshot + serialize/deserialize + invalid payload reject -- Scenario id: `editor.state.layout_persistence` -- Build target: `editor_ui_layout_persistence_validation` -- Executable name: `XCUIEditorLayoutPersistenceValidation` -- Validation scope: layout save / load / reset / invalid payload reject only, no business panels +- `editor.state.shortcut_dispatch` + Build target: `editor_ui_shortcut_dispatch_validation` + Executable: `XCUIEditorShortcutDispatchValidation.exe` + Scope: shortcut match + scope + suppression + command dispatch Run: -```bash -cmake --build build --config Debug --target editor_ui_workspace_shell_compose_validation -``` - -Then launch `XCUIEditorWorkspaceShellComposeValidation.exe` from the build output, or run it from your IDE by target name. - -Controls: - -- Drag authored splitters to verify live resize and min clamps. -- Click `Document A/B/C` to verify only the selected tab placeholder is visible. -- Press `F12` to write screenshots into `workspace_shell_compose/captures/`. -- Authored `.xcui` and `.xctheme` changes hot reload while the host is running. - -Panel session flow controls: - -- Click `Hide Active / Show Doc A / Close Doc B / Open Doc B / Activate Details / Reset`. -- Check `Last command` shows `Changed / NoOp / Rejected` consistently with the current state. -- Press `F12` to write screenshots into `state/panel_session_flow/captures/`. - -Layout persistence controls: - -- Click `Hide Active -> Save Layout -> Close Doc B -> Load Layout`. -- Check `Saved` summary captures the expected active/visible state, and `Load Layout` restores it. -- Click `Load Invalid` and confirm the result is `Rejected` while current state remains unchanged. -- Press `F12` to write screenshots into `state/layout_persistence/captures/`. - -Build: - ```bash cmake --build build --config Debug --target editor_ui_integration_tests ``` + +Selected controls: + +- `shell/workspace_shell_compose/` + Drag splitters, switch `Document A/B/C`, press `F12`. + +- `shell/menu_bar_basic/` + Click `File / Window / Layout`, move the mouse across menu items, click outside the menu or press `Esc`, press `F12`. + +- `state/panel_session_flow/` + Click `Hide Active / Show Doc A / Close Doc B / Open Doc B / Activate Details / Reset`, press `F12`. + +- `state/layout_persistence/` + Click `Hide Active -> Save Layout -> Close Doc B -> Load Layout`, click `Load Invalid`, press `F12`. + +- `state/shortcut_dispatch/` + Press `Ctrl+P / Ctrl+H / Ctrl+W / Ctrl+O / Ctrl+R`, toggle `Text Input`, press `F12`. diff --git a/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp b/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp index 3e2ecef9..668a82aa 100644 --- a/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp +++ b/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp @@ -28,13 +28,13 @@ fs::path RepoRelative(const char* relativePath) { const std::array& GetEditorValidationScenarios() { static const std::array scenarios = { { { - "editor.shell.workspace_compose", + "editor.shell.workspace_shell_compose", UIValidationDomain::Editor, "shell", "Editor 壳层 | 工作区组合", - RepoRelative("tests/UI/Editor/integration/workspace_shell_compose/View.xcui"), + RepoRelative("tests/UI/Editor/integration/shell/workspace_shell_compose/View.xcui"), RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"), - RepoRelative("tests/UI/Editor/integration/workspace_shell_compose/captures") + RepoRelative("tests/UI/Editor/integration/shell/workspace_shell_compose/captures") } } }; diff --git a/tests/UI/Editor/integration/shell/CMakeLists.txt b/tests/UI/Editor/integration/shell/CMakeLists.txt new file mode 100644 index 00000000..76512b4f --- /dev/null +++ b/tests/UI/Editor/integration/shell/CMakeLists.txt @@ -0,0 +1,4 @@ +add_subdirectory(workspace_shell_compose) +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/menu_bar_basic/CMakeLists.txt") + add_subdirectory(menu_bar_basic) +endif() diff --git a/tests/UI/Editor/integration/shell/menu_bar_basic/CMakeLists.txt b/tests/UI/Editor/integration/shell/menu_bar_basic/CMakeLists.txt new file mode 100644 index 00000000..f69e0fb1 --- /dev/null +++ b/tests/UI/Editor/integration/shell/menu_bar_basic/CMakeLists.txt @@ -0,0 +1,29 @@ +add_executable(editor_ui_menu_bar_basic_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_menu_bar_basic_validation PRIVATE + ${CMAKE_SOURCE_DIR}/engine/include + ${CMAKE_SOURCE_DIR}/new_editor/include +) + +target_compile_definitions(editor_ui_menu_bar_basic_validation PRIVATE + UNICODE + _UNICODE + XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}" +) + +if(MSVC) + target_compile_options(editor_ui_menu_bar_basic_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_menu_bar_basic_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_menu_bar_basic_validation PRIVATE + XCNewEditorLib + XCNewEditorHost +) + +set_target_properties(editor_ui_menu_bar_basic_validation PROPERTIES + OUTPUT_NAME "XCUIEditorMenuBarBasicValidation" +) diff --git a/tests/UI/Editor/integration/workspace_shell_compose/captures/.gitkeep b/tests/UI/Editor/integration/shell/menu_bar_basic/captures/.gitkeep similarity index 100% rename from tests/UI/Editor/integration/workspace_shell_compose/captures/.gitkeep rename to tests/UI/Editor/integration/shell/menu_bar_basic/captures/.gitkeep diff --git a/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp b/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp new file mode 100644 index 00000000..00ff5549 --- /dev/null +++ b/tests/UI/Editor/integration/shell/menu_bar_basic/main.cpp @@ -0,0 +1,935 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::NewEditor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::NewEditor::BuildUIEditorResolvedMenuModel; +using XCEngine::NewEditor::BuildUIEditorWorkspacePanel; +using XCEngine::NewEditor::BuildUIEditorWorkspaceSplit; +using XCEngine::NewEditor::BuildUIEditorWorkspaceTabStack; +using XCEngine::NewEditor::CollectUIEditorWorkspaceVisiblePanels; +using XCEngine::NewEditor::FindUIEditorPanelSessionState; +using XCEngine::NewEditor::GetUIEditorCommandDispatchStatusName; +using XCEngine::NewEditor::GetUIEditorMenuItemKindName; +using XCEngine::NewEditor::GetUIEditorWorkspaceCommandStatusName; +using XCEngine::NewEditor::UIEditorCommandDispatchResult; +using XCEngine::NewEditor::UIEditorCommandDispatcher; +using XCEngine::NewEditor::UIEditorCommandPanelSource; +using XCEngine::NewEditor::UIEditorCommandRegistry; +using XCEngine::NewEditor::UIEditorMenuCheckedStateSource; +using XCEngine::NewEditor::UIEditorMenuDescriptor; +using XCEngine::NewEditor::UIEditorMenuItemDescriptor; +using XCEngine::NewEditor::UIEditorMenuItemKind; +using XCEngine::NewEditor::UIEditorMenuModel; +using XCEngine::NewEditor::UIEditorPanelRegistry; +using XCEngine::NewEditor::UIEditorResolvedMenuDescriptor; +using XCEngine::NewEditor::UIEditorResolvedMenuItem; +using XCEngine::NewEditor::UIEditorResolvedMenuModel; +using XCEngine::NewEditor::UIEditorShortcutManager; +using XCEngine::NewEditor::UIEditorWorkspaceCommandKind; +using XCEngine::NewEditor::UIEditorWorkspaceCommandStatus; +using XCEngine::NewEditor::UIEditorWorkspaceController; +using XCEngine::NewEditor::UIEditorWorkspaceModel; +using XCEngine::NewEditor::UIEditorWorkspaceSession; +using XCEngine::NewEditor::UIEditorWorkspaceSplitAxis; +using XCEngine::NewEditor::ValidateUIEditorMenuModel; +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::UIShortcutBinding; +using XCEngine::UI::UIShortcutScope; +using XCEngine::XCUI::Host::AutoScreenshotController; +using XCEngine::XCUI::Host::NativeRenderer; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorMenuBarBasicValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Menu Bar Basic"; + +constexpr UIColor kWindowBg(0.14f, 0.14f, 0.14f, 1.0f); +constexpr UIColor kCardBg(0.19f, 0.19f, 0.19f, 1.0f); +constexpr UIColor kCardBorder(0.29f, 0.29f, 0.29f, 1.0f); +constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f); +constexpr UIColor kTextMuted(0.70f, 0.70f, 0.70f, 1.0f); +constexpr UIColor kTextDisabled(0.50f, 0.50f, 0.50f, 1.0f); +constexpr UIColor kAccent(0.82f, 0.82f, 0.82f, 1.0f); +constexpr UIColor kSuccess(0.43f, 0.71f, 0.47f, 1.0f); +constexpr UIColor kWarning(0.78f, 0.60f, 0.30f, 1.0f); +constexpr UIColor kDanger(0.78f, 0.34f, 0.34f, 1.0f); +constexpr UIColor kMenuBarBg(0.16f, 0.16f, 0.16f, 1.0f); +constexpr UIColor kMenuButtonBg(0.24f, 0.24f, 0.24f, 1.0f); +constexpr UIColor kMenuButtonHover(0.30f, 0.30f, 0.30f, 1.0f); +constexpr UIColor kMenuButtonOpen(0.36f, 0.36f, 0.36f, 1.0f); +constexpr UIColor kMenuDropBg(0.17f, 0.17f, 0.17f, 1.0f); +constexpr UIColor kMenuItemHover(0.28f, 0.28f, 0.28f, 1.0f); +constexpr UIColor kMenuDivider(0.32f, 0.32f, 0.32f, 1.0f); +constexpr UIColor kIndicatorBg(0.23f, 0.23f, 0.23f, 1.0f); + +struct MenuButtonLayout { + std::string menuId = {}; + std::string label = {}; + UIRect rect = {}; +}; + +struct MenuItemLayout { + std::string menuId = {}; + std::string itemId = {}; + UIEditorMenuItemKind kind = UIEditorMenuItemKind::Command; + std::string label = {}; + std::string commandId = {}; + std::string shortcutText = {}; + UIRect rect = {}; + bool enabled = false; + bool checked = false; +}; + +std::filesystem::path ResolveRepoRootPath(); +UIEditorPanelRegistry BuildPanelRegistry(); +UIEditorWorkspaceModel BuildWorkspace(); +UIEditorCommandRegistry BuildCommandRegistry(); +UIShortcutBinding MakeBinding(std::string commandId, KeyCode keyCode, bool shift = false); +UIEditorShortcutManager BuildShortcutManager(); +UIEditorMenuModel BuildMenuModel(); +bool ContainsPoint(const UIRect& rect, float x, float y); +std::string JoinVisiblePanelIds(const UIEditorWorkspaceModel& workspace, const UIEditorWorkspaceSession& session); +void DrawCard(UIDrawList& drawList, const UIRect& rect, std::string_view title, std::string_view subtitle = {}); +UIColor ResolveResultColor(std::string_view statusLabel); + +class ScenarioApp { +public: + int Run(HINSTANCE hInstance, int nCmdShow); + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); + + bool Initialize(HINSTANCE hInstance, int nCmdShow); + void Shutdown(); + void ResetScenario(); + void OnResize(UINT width, UINT height); + void RenderFrame(); + void HandleMouseMove(float x, float y); + void HandleClick(float x, float y); + void HandleKeyDown(UINT keyCode); + void SetDispatchResult(std::string actionName, const UIEditorCommandDispatchResult& result); + void SetCustomResult(std::string actionName, std::string statusLabel, std::string message); + void DrawMenuBar(UIDrawList& drawList, const UIRect& rect, const UIEditorResolvedMenuModel& resolvedModel); + void DrawOpenMenu(UIDrawList& drawList, const UIEditorResolvedMenuDescriptor& menu, const UIRect& anchorRect); + void BuildDrawData(UIDrawData& drawData, float width, float height); + + HWND m_hwnd = nullptr; + HINSTANCE m_hInstance = nullptr; + ATOM m_windowClassAtom = 0; + NativeRenderer m_renderer = {}; + AutoScreenshotController m_autoScreenshot = {}; + UIEditorWorkspaceController m_controller = {}; + UIEditorCommandDispatcher m_commandDispatcher = {}; + UIEditorShortcutManager m_shortcutManager = {}; + UIEditorMenuModel m_menuModel = {}; + std::vector m_menuButtons = {}; + std::vector m_menuItems = {}; + std::string m_openMenuId = {}; + std::string m_hoveredMenuId = {}; + std::string m_hoveredItemId = {}; + std::string m_lastActionName = {}; + std::string m_lastStatusLabel = {}; + std::string m_lastMessage = {}; + UIColor m_lastStatusColor = kTextMuted; +}; + +std::filesystem::path ResolveRepoRootPath() { + std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT; + if (root.size() >= 2u && root.front() == '"' && root.back() == '"') { + root = root.substr(1u, root.size() - 2u); + } + + return std::filesystem::path(root).lexically_normal(); +} + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.66f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +UIEditorCommandRegistry BuildCommandRegistry() { + UIEditorCommandRegistry registry = {}; + registry.commands = { + { + "workspace.show_details", + "Show Details", + { UIEditorWorkspaceCommandKind::ShowPanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.hide_active", + "Hide Active", + { UIEditorWorkspaceCommandKind::HidePanel, UIEditorCommandPanelSource::ActivePanel, {} } + }, + { + "workspace.activate_details", + "Activate Details", + { UIEditorWorkspaceCommandKind::ActivatePanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.reset", + "Reset Workspace", + { UIEditorWorkspaceCommandKind::ResetWorkspace, UIEditorCommandPanelSource::None, {} } + } + }; + return registry; +} + +UIShortcutBinding MakeBinding( + std::string commandId, + KeyCode keyCode, + bool shift) { + UIShortcutBinding binding = {}; + binding.commandId = std::move(commandId); + binding.scope = UIShortcutScope::Global; + binding.triggerEventType = UIInputEventType::KeyDown; + binding.chord.keyCode = static_cast(keyCode); + binding.chord.modifiers.control = true; + binding.chord.modifiers.shift = shift; + return binding; +} + +UIEditorShortcutManager BuildShortcutManager() { + UIEditorShortcutManager manager(BuildCommandRegistry()); + manager.RegisterBinding(MakeBinding("workspace.show_details", KeyCode::D, true)); + manager.RegisterBinding(MakeBinding("workspace.hide_active", KeyCode::H)); + manager.RegisterBinding(MakeBinding("workspace.activate_details", KeyCode::D)); + manager.RegisterBinding(MakeBinding("workspace.reset", KeyCode::R)); + return manager; +} + +UIEditorMenuModel BuildMenuModel() { + UIEditorMenuModel model = {}; + model.menus = { + UIEditorMenuDescriptor{ + "file", + "File", + { + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "file-reset", + {}, + "workspace.reset", + {}, + {} + } + } + }, + UIEditorMenuDescriptor{ + "window", + "Window", + { + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "window-show-details", + {}, + "workspace.show_details", + { UIEditorMenuCheckedStateSource::PanelVisible, "details" }, + {} + }, + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "window-hide-active", + {}, + "workspace.hide_active", + {}, + {} + }, + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "window-activate-details", + {}, + "workspace.activate_details", + { UIEditorMenuCheckedStateSource::PanelActive, "details" }, + {} + } + } + }, + UIEditorMenuDescriptor{ + "layout", + "Layout", + { + UIEditorMenuItemDescriptor{ + UIEditorMenuItemKind::Command, + "layout-reset", + {}, + "workspace.reset", + {}, + {} + } + } + } + }; + return model; +} + +bool ContainsPoint(const UIRect& rect, float x, float y) { + return x >= rect.x && + x <= rect.x + rect.width && + y >= rect.y && + y <= rect.y + rect.height; +} + +std::string JoinVisiblePanelIds( + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session) { + const auto visiblePanels = CollectUIEditorWorkspaceVisiblePanels(workspace, session); + if (visiblePanels.empty()) { + return "(none)"; + } + + std::ostringstream stream = {}; + for (std::size_t index = 0; index < visiblePanels.size(); ++index) { + if (index > 0u) { + stream << ", "; + } + stream << visiblePanels[index].panelId; + } + return stream.str(); +} + +void DrawCard( + UIDrawList& drawList, + const UIRect& rect, + std::string_view title, + std::string_view subtitle) { + drawList.AddFilledRect(rect, kCardBg, 12.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 12.0f); + drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 16.0f), std::string(title), kTextPrimary, 17.0f); + if (!subtitle.empty()) { + drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 42.0f), std::string(subtitle), kTextMuted, 12.0f); + } +} + +UIColor ResolveResultColor(std::string_view statusLabel) { + if (statusLabel == "Changed" || statusLabel == "Dispatched") { + return kSuccess; + } + if (statusLabel == "NoOp" || statusLabel == "Dismissed" || statusLabel == "Disabled") { + return kWarning; + } + if (statusLabel == "Rejected") { + return kDanger; + } + return kTextMuted; +} + +int ScenarioApp::Run(HINSTANCE hInstance, int nCmdShow) { + if (!Initialize(hInstance, nCmdShow)) { + Shutdown(); + return 1; + } + + MSG message = {}; + while (message.message != WM_QUIT) { + if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + continue; + } + + RenderFrame(); + Sleep(8); + } + + Shutdown(); + return static_cast(message.wParam); +} + +LRESULT CALLBACK ScenarioApp::WndProc( + HWND hwnd, + UINT message, + WPARAM wParam, + LPARAM lParam) { + if (message == WM_NCCREATE) { + const auto* createStruct = reinterpret_cast(lParam); + auto* app = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(app)); + return TRUE; + } + + auto* app = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + switch (message) { + case WM_SIZE: + if (app != nullptr && wParam != SIZE_MINIMIZED) { + app->OnResize(static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam))); + } + return 0; + case WM_PAINT: + if (app != nullptr) { + PAINTSTRUCT paintStruct = {}; + BeginPaint(hwnd, &paintStruct); + app->RenderFrame(); + EndPaint(hwnd, &paintStruct); + return 0; + } + break; + case WM_MOUSEMOVE: + if (app != nullptr) { + app->HandleMouseMove( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_LBUTTONUP: + if (app != nullptr) { + app->HandleClick( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr) { + if (wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + } else { + app->HandleKeyDown(static_cast(wParam)); + } + return 0; + } + break; + case WM_ERASEBKGND: + return 1; + case WM_DESTROY: + if (app != nullptr) { + app->m_hwnd = nullptr; + } + PostQuitMessage(0); + return 0; + default: + break; + } + + return DefWindowProcW(hwnd, message, wParam, lParam); +} + +bool ScenarioApp::Initialize(HINSTANCE hInstance, int nCmdShow) { + m_hInstance = hInstance; + ResetScenario(); + + WNDCLASSEXW windowClass = {}; + windowClass.cbSize = sizeof(windowClass); + windowClass.style = CS_HREDRAW | CS_VREDRAW; + windowClass.lpfnWndProc = &ScenarioApp::WndProc; + windowClass.hInstance = hInstance; + windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); + windowClass.lpszClassName = kWindowClassName; + + m_windowClassAtom = RegisterClassExW(&windowClass); + if (m_windowClassAtom == 0) { + return false; + } + + m_hwnd = CreateWindowExW( + 0, + kWindowClassName, + kWindowTitle, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 1360, + 900, + nullptr, + nullptr, + hInstance, + this); + if (m_hwnd == nullptr) { + return false; + } + + ShowWindow(m_hwnd, nCmdShow); + UpdateWindow(m_hwnd); + + if (!m_renderer.Initialize(m_hwnd)) { + return false; + } + + m_autoScreenshot.Initialize( + (ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/menu_bar_basic/captures") + .lexically_normal()); + return true; +} + +void ScenarioApp::Shutdown() { + m_autoScreenshot.Shutdown(); + m_renderer.Shutdown(); + + if (m_hwnd != nullptr && IsWindow(m_hwnd)) { + DestroyWindow(m_hwnd); + } + m_hwnd = nullptr; + + if (m_windowClassAtom != 0 && m_hInstance != nullptr) { + UnregisterClassW(kWindowClassName, m_hInstance); + m_windowClassAtom = 0; + } +} + +void ScenarioApp::ResetScenario() { + m_controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + m_commandDispatcher = UIEditorCommandDispatcher(BuildCommandRegistry()); + m_shortcutManager = BuildShortcutManager(); + m_menuModel = BuildMenuModel(); + m_openMenuId.clear(); + m_hoveredMenuId.clear(); + m_hoveredItemId.clear(); + m_menuButtons.clear(); + m_menuItems.clear(); + SetCustomResult( + "等待操作", + "Pending", + "先点 File / Window / Layout,确认一次只会打开一个菜单;再点菜单外区域或按 Escape 关闭。"); +} + +void ScenarioApp::OnResize(UINT width, UINT height) { + if (width == 0 || height == 0) { + return; + } + + m_renderer.Resize(width, height); +} + +void ScenarioApp::RenderFrame() { + if (m_hwnd == nullptr) { + return; + } + + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(clientRect.right - clientRect.left, 1L)); + const float height = static_cast((std::max)(clientRect.bottom - clientRect.top, 1L)); + + UIDrawData drawData = {}; + BuildDrawData(drawData, width, height); + + const bool framePresented = m_renderer.Render(drawData); + m_autoScreenshot.CaptureIfRequested( + m_renderer, + drawData, + static_cast(width), + static_cast(height), + framePresented); +} + +void ScenarioApp::HandleMouseMove(float x, float y) { + std::string hoveredMenuId = {}; + std::string hoveredItemId = {}; + + for (const MenuButtonLayout& button : m_menuButtons) { + if (ContainsPoint(button.rect, x, y)) { + hoveredMenuId = button.menuId; + break; + } + } + + for (const MenuItemLayout& item : m_menuItems) { + if (ContainsPoint(item.rect, x, y)) { + hoveredItemId = item.itemId; + break; + } + } + + if (hoveredMenuId != m_hoveredMenuId || hoveredItemId != m_hoveredItemId) { + m_hoveredMenuId = std::move(hoveredMenuId); + m_hoveredItemId = std::move(hoveredItemId); + InvalidateRect(m_hwnd, nullptr, FALSE); + } +} + +void ScenarioApp::HandleClick(float x, float y) { + for (const MenuButtonLayout& button : m_menuButtons) { + if (!ContainsPoint(button.rect, x, y)) { + continue; + } + + if (m_openMenuId == button.menuId) { + m_openMenuId.clear(); + m_hoveredItemId.clear(); + SetCustomResult("关闭菜单", "NoOp", "再次点击当前菜单按钮,菜单已关闭。"); + } else { + m_openMenuId = button.menuId; + m_hoveredItemId.clear(); + SetCustomResult( + "打开菜单", + "Changed", + "当前激活菜单: " + button.label + "。确认同一时刻只存在一个下拉菜单。"); + } + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + for (const MenuItemLayout& item : m_menuItems) { + if (!ContainsPoint(item.rect, x, y)) { + continue; + } + + if (item.kind != UIEditorMenuItemKind::Command) { + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + if (!item.enabled) { + SetCustomResult( + "菜单项不可执行", + "Disabled", + "当前工作区状态下 `" + item.label + "` 不可执行。"); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + const UIEditorCommandDispatchResult result = + m_commandDispatcher.Dispatch(item.commandId, m_controller); + m_openMenuId.clear(); + m_hoveredItemId.clear(); + SetDispatchResult(item.label, result); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + if (!m_openMenuId.empty()) { + m_openMenuId.clear(); + m_hoveredItemId.clear(); + SetCustomResult("菜单失焦", "Dismissed", "点击菜单外区域,菜单已关闭。"); + InvalidateRect(m_hwnd, nullptr, FALSE); + } +} + +void ScenarioApp::HandleKeyDown(UINT keyCode) { + switch (keyCode) { + case VK_ESCAPE: + if (!m_openMenuId.empty()) { + m_openMenuId.clear(); + m_hoveredItemId.clear(); + SetCustomResult("Esc 关闭菜单", "Dismissed", "按下 Escape 后,菜单已关闭。"); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + break; + case 'R': + SetDispatchResult( + "键盘 Reset Workspace", + m_commandDispatcher.Dispatch("workspace.reset", m_controller)); + InvalidateRect(m_hwnd, nullptr, FALSE); + break; + default: + break; + } +} + +void ScenarioApp::SetDispatchResult( + std::string actionName, + const UIEditorCommandDispatchResult& result) { + m_lastActionName = std::move(actionName); + if (result.commandExecuted) { + m_lastStatusLabel = + std::string(GetUIEditorWorkspaceCommandStatusName(result.commandResult.status)); + m_lastMessage = + result.displayName + " -> " + result.commandResult.message; + } else { + m_lastStatusLabel = + std::string(GetUIEditorCommandDispatchStatusName(result.status)); + m_lastMessage = result.message; + } + m_lastStatusColor = ResolveResultColor(m_lastStatusLabel); +} + +void ScenarioApp::SetCustomResult( + std::string actionName, + std::string statusLabel, + std::string message) { + m_lastActionName = std::move(actionName); + m_lastStatusLabel = std::move(statusLabel); + m_lastMessage = std::move(message); + m_lastStatusColor = ResolveResultColor(m_lastStatusLabel); +} + +void ScenarioApp::DrawMenuBar( + UIDrawList& drawList, + const UIRect& rect, + const UIEditorResolvedMenuModel& resolvedModel) { + drawList.AddFilledRect(rect, kMenuBarBg, 8.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 8.0f); + + m_menuButtons.clear(); + m_menuItems.clear(); + + float buttonX = rect.x + 12.0f; + for (const UIEditorResolvedMenuDescriptor& menu : resolvedModel.menus) { + const bool open = m_openMenuId == menu.menuId; + const bool hovered = m_hoveredMenuId == menu.menuId; + const float buttonWidth = 96.0f; + const UIRect buttonRect(buttonX, rect.y + 6.0f, buttonWidth, rect.height - 12.0f); + + drawList.AddFilledRect( + buttonRect, + open ? kMenuButtonOpen : (hovered ? kMenuButtonHover : kMenuButtonBg), + 6.0f); + drawList.AddRectOutline(buttonRect, kCardBorder, 1.0f, 6.0f); + drawList.AddText( + UIPoint(buttonRect.x + 14.0f, buttonRect.y + 10.0f), + menu.label, + kTextPrimary, + 14.0f); + + m_menuButtons.push_back({ menu.menuId, menu.label, buttonRect }); + + buttonX += buttonWidth + 10.0f; + } +} + +void ScenarioApp::DrawOpenMenu( + UIDrawList& drawList, + const UIEditorResolvedMenuDescriptor& menu, + const UIRect& anchorRect) { + float contentHeight = 10.0f; + for (const UIEditorResolvedMenuItem& item : menu.items) { + contentHeight += item.kind == UIEditorMenuItemKind::Separator ? 12.0f : 34.0f; + } + contentHeight += 8.0f; + + const UIRect dropRect(anchorRect.x, anchorRect.y + anchorRect.height + 6.0f, 320.0f, contentHeight); + drawList.AddFilledRect(dropRect, kMenuDropBg, 8.0f); + drawList.AddRectOutline(dropRect, kCardBorder, 1.0f, 8.0f); + + float itemY = dropRect.y + 8.0f; + for (const UIEditorResolvedMenuItem& item : menu.items) { + if (item.kind == UIEditorMenuItemKind::Separator) { + drawList.AddFilledRect( + UIRect(dropRect.x + 12.0f, itemY + 4.0f, dropRect.width - 24.0f, 1.0f), + kMenuDivider); + itemY += 12.0f; + continue; + } + + const UIRect itemRect(dropRect.x + 8.0f, itemY, dropRect.width - 16.0f, 30.0f); + const bool hovered = m_hoveredItemId == item.itemId; + if (hovered) { + drawList.AddFilledRect(itemRect, kMenuItemHover, 6.0f); + } + + if (item.checked) { + drawList.AddFilledRect( + UIRect(itemRect.x + 10.0f, itemRect.y + 8.0f, 10.0f, 10.0f), + kAccent, + 3.0f); + } else { + drawList.AddRectOutline( + UIRect(itemRect.x + 10.0f, itemRect.y + 8.0f, 10.0f, 10.0f), + kMenuDivider, + 1.0f, + 3.0f); + } + + drawList.AddText( + UIPoint(itemRect.x + 30.0f, itemRect.y + 7.0f), + item.label, + item.enabled ? kTextPrimary : kTextDisabled, + 13.0f); + if (!item.shortcutText.empty()) { + drawList.AddText( + UIPoint(itemRect.x + itemRect.width - 90.0f, itemRect.y + 7.0f), + item.shortcutText, + item.enabled ? kTextMuted : kTextDisabled, + 12.0f); + } + + m_menuItems.push_back( + { menu.menuId, item.itemId, item.kind, item.label, item.commandId, item.shortcutText, itemRect, item.enabled, item.checked }); + itemY += 34.0f; + } +} + +void ScenarioApp::BuildDrawData(UIDrawData& drawData, float width, float height) { + const auto menuValidation = + ValidateUIEditorMenuModel(m_menuModel, m_commandDispatcher.GetCommandRegistry()); + const auto shortcutValidation = m_shortcutManager.ValidateConfiguration(); + const auto resolvedModel = + BuildUIEditorResolvedMenuModel( + m_menuModel, + m_commandDispatcher, + m_controller, + &m_shortcutManager); + + const UIEditorWorkspaceModel& workspace = m_controller.GetWorkspace(); + const UIEditorWorkspaceSession& session = m_controller.GetSession(); + const auto* detailsState = FindUIEditorPanelSessionState(session, "details"); + + UIDrawList& drawList = drawData.EmplaceDrawList("Editor Menu Bar Basic"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + const float margin = 20.0f; + const UIRect headerRect(margin, margin, width - margin * 2.0f, 184.0f); + const UIRect shellRect(margin, headerRect.y + headerRect.height + 16.0f, width * 0.58f, height - 320.0f); + const UIRect stateRect(shellRect.x + shellRect.width + 16.0f, shellRect.y, width - shellRect.width - margin * 2.0f - 16.0f, height - 320.0f); + const UIRect footerRect(margin, height - 100.0f, width - margin * 2.0f, 80.0f); + + DrawCard( + drawList, + headerRect, + "测试内容:Editor Menu 基础壳层验证", + "只验证 MenuBar / 下拉展开 / hover / 菜单关闭 / command dispatch;不验证业务面板,不验证完整编辑器菜单体系。"); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 70.0f), "1. 点击 File / Window / Layout,确认同一时刻只会有一个菜单展开。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 92.0f), "2. 展开菜单后移动鼠标,确认 hover 高亮稳定,disabled 项不会误显示成可点击。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 114.0f), "3. 点击菜单外区域或按 Escape,菜单必须立即关闭;Footer 会显示 Dismissed。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 136.0f), "4. 点 Window -> Activate Details,再点 Window -> Hide Active,可检查 Details 的 checked 状态是否随 visible/active 正确变化。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 158.0f), "5. 菜单右侧 shortcut 文案只做显示验证;F12 保存截图。", kTextPrimary, 13.0f); + + DrawCard(drawList, shellRect, "操作区", "这里只放菜单栏和当前打开的下拉菜单。"); + DrawCard(drawList, stateRect, "状态摘要", "看 open/hover/current workspace,确认菜单交互边界和命令派发结果。"); + DrawCard(drawList, footerRect, "最近结果", "显示最近一次菜单交互、命令状态和截图输出。"); + + const UIRect menuBarRect(shellRect.x + 18.0f, shellRect.y + 74.0f, shellRect.width - 36.0f, 46.0f); + DrawMenuBar(drawList, menuBarRect, resolvedModel); + + const UIRect shellInfoRect(shellRect.x + 18.0f, shellRect.y + 144.0f, shellRect.width - 36.0f, 200.0f); + drawList.AddFilledRect(shellInfoRect, kIndicatorBg, 8.0f); + drawList.AddRectOutline(shellInfoRect, kCardBorder, 1.0f, 8.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 14.0f), "Open menu", kTextMuted, 12.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 34.0f), m_openMenuId.empty() ? "(none)" : m_openMenuId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 62.0f), "Hover menu", kTextMuted, 12.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 82.0f), m_hoveredMenuId.empty() ? "(none)" : m_hoveredMenuId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 110.0f), "Hover item", kTextMuted, 12.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 130.0f), m_hoveredItemId.empty() ? "(none)" : m_hoveredItemId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(shellInfoRect.x + 14.0f, shellInfoRect.y + 158.0f), "提示:按 R 可直接触发 Reset Workspace。", kTextMuted, 12.0f); + + if (!m_openMenuId.empty()) { + const UIEditorResolvedMenuDescriptor* openMenu = nullptr; + for (const UIEditorResolvedMenuDescriptor& menu : resolvedModel.menus) { + if (menu.menuId == m_openMenuId) { + openMenu = &menu; + break; + } + } + + const MenuButtonLayout* openButton = nullptr; + for (const MenuButtonLayout& button : m_menuButtons) { + if (button.menuId == m_openMenuId) { + openButton = &button; + break; + } + } + + if (openMenu != nullptr && openButton != nullptr) { + DrawOpenMenu(drawList, *openMenu, openButton->rect); + } + } + + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 72.0f), "Current Workspace", kAccent, 15.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 100.0f), "active panel", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 120.0f), workspace.activePanelId.empty() ? "(none)" : workspace.activePanelId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 150.0f), "visible panels", kTextMuted, 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 170.0f), JoinVisiblePanelIds(workspace, session), kTextPrimary, 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 200.0f), "details visible", kTextMuted, 12.0f); + drawList.AddText( + UIPoint(stateRect.x + 18.0f, stateRect.y + 220.0f), + detailsState != nullptr && detailsState->visible ? "true" : "false", + detailsState != nullptr && detailsState->visible ? kSuccess : kWarning, + 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 250.0f), "details active", kTextMuted, 12.0f); + drawList.AddText( + UIPoint(stateRect.x + 18.0f, stateRect.y + 270.0f), + workspace.activePanelId == "details" ? "true" : "false", + workspace.activePanelId == "details" ? kSuccess : kTextMuted, + 14.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 302.0f), "menu validation", kTextMuted, 12.0f); + drawList.AddText( + UIPoint(stateRect.x + 18.0f, stateRect.y + 322.0f), + menuValidation.IsValid() ? "OK" : menuValidation.message, + menuValidation.IsValid() ? kSuccess : kDanger, + 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 352.0f), "shortcut validation", kTextMuted, 12.0f); + drawList.AddText( + UIPoint(stateRect.x + 18.0f, stateRect.y + 372.0f), + shortcutValidation.IsValid() ? "OK" : shortcutValidation.message, + shortcutValidation.IsValid() ? kSuccess : kDanger, + 12.0f); + drawList.AddText(UIPoint(stateRect.x + 18.0f, stateRect.y + 402.0f), "menu model item kinds", kTextMuted, 12.0f); + drawList.AddText( + UIPoint(stateRect.x + 18.0f, stateRect.y + 422.0f), + std::string("Command / ") + std::string(GetUIEditorMenuItemKindName(UIEditorMenuItemKind::Separator)) + " / " + + std::string(GetUIEditorMenuItemKindName(UIEditorMenuItemKind::Submenu)), + kTextPrimary, + 12.0f); + + drawList.AddText( + UIPoint(footerRect.x + 18.0f, footerRect.y + 28.0f), + "Last interaction: " + m_lastActionName + " | Result: " + m_lastStatusLabel, + m_lastStatusColor, + 13.0f); + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 48.0f), m_lastMessage, kTextPrimary, 12.0f); + + const std::string captureSummary = + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("F12 -> tests/UI/Editor/integration/shell/menu_bar_basic/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText( + UIPoint(footerRect.x + 18.0f, footerRect.y + 66.0f), + captureSummary, + kTextMuted, + 12.0f); +} + +} // namespace + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + ScenarioApp app; + return app.Run(hInstance, nCmdShow); +} diff --git a/tests/UI/Editor/integration/workspace_shell_compose/CMakeLists.txt b/tests/UI/Editor/integration/shell/workspace_shell_compose/CMakeLists.txt similarity index 100% rename from tests/UI/Editor/integration/workspace_shell_compose/CMakeLists.txt rename to tests/UI/Editor/integration/shell/workspace_shell_compose/CMakeLists.txt diff --git a/tests/UI/Editor/integration/workspace_shell_compose/View.xcui b/tests/UI/Editor/integration/shell/workspace_shell_compose/View.xcui similarity index 100% rename from tests/UI/Editor/integration/workspace_shell_compose/View.xcui rename to tests/UI/Editor/integration/shell/workspace_shell_compose/View.xcui diff --git a/tests/UI/Editor/integration/shell/workspace_shell_compose/captures/.gitkeep b/tests/UI/Editor/integration/shell/workspace_shell_compose/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/shell/workspace_shell_compose/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/workspace_shell_compose/main.cpp b/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp similarity index 80% rename from tests/UI/Editor/integration/workspace_shell_compose/main.cpp rename to tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp index 74b12b9b..d185e5ba 100644 --- a/tests/UI/Editor/integration/workspace_shell_compose/main.cpp +++ b/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp @@ -4,5 +4,5 @@ int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { return XCEngine::Tests::EditorUI::RunEditorUIValidationApp( hInstance, nCmdShow, - "editor.shell.workspace_compose"); + "editor.shell.workspace_shell_compose"); } diff --git a/tests/UI/Editor/integration/state/CMakeLists.txt b/tests/UI/Editor/integration/state/CMakeLists.txt index 2a801b14..50f96325 100644 --- a/tests/UI/Editor/integration/state/CMakeLists.txt +++ b/tests/UI/Editor/integration/state/CMakeLists.txt @@ -1,2 +1,3 @@ add_subdirectory(panel_session_flow) add_subdirectory(layout_persistence) +add_subdirectory(shortcut_dispatch) diff --git a/tests/UI/Editor/integration/state/shortcut_dispatch/CMakeLists.txt b/tests/UI/Editor/integration/state/shortcut_dispatch/CMakeLists.txt new file mode 100644 index 00000000..3cbe90bf --- /dev/null +++ b/tests/UI/Editor/integration/state/shortcut_dispatch/CMakeLists.txt @@ -0,0 +1,29 @@ +add_executable(editor_ui_shortcut_dispatch_validation WIN32 + main.cpp +) + +target_include_directories(editor_ui_shortcut_dispatch_validation PRIVATE + ${CMAKE_SOURCE_DIR}/engine/include + ${CMAKE_SOURCE_DIR}/new_editor/include +) + +target_compile_definitions(editor_ui_shortcut_dispatch_validation PRIVATE + UNICODE + _UNICODE + XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}" +) + +if(MSVC) + target_compile_options(editor_ui_shortcut_dispatch_validation PRIVATE /utf-8 /FS) + set_property(TARGET editor_ui_shortcut_dispatch_validation PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL") +endif() + +target_link_libraries(editor_ui_shortcut_dispatch_validation PRIVATE + XCNewEditorLib + XCNewEditorHost +) + +set_target_properties(editor_ui_shortcut_dispatch_validation PROPERTIES + OUTPUT_NAME "XCUIEditorShortcutDispatchValidation" +) diff --git a/tests/UI/Editor/integration/state/shortcut_dispatch/captures/.gitkeep b/tests/UI/Editor/integration/state/shortcut_dispatch/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/state/shortcut_dispatch/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/state/shortcut_dispatch/main.cpp b/tests/UI/Editor/integration/state/shortcut_dispatch/main.cpp new file mode 100644 index 00000000..0fdcfdab --- /dev/null +++ b/tests/UI/Editor/integration/state/shortcut_dispatch/main.cpp @@ -0,0 +1,641 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::NewEditor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::NewEditor::BuildUIEditorWorkspacePanel; +using XCEngine::NewEditor::BuildUIEditorWorkspaceSplit; +using XCEngine::NewEditor::BuildUIEditorWorkspaceTabStack; +using XCEngine::NewEditor::CollectUIEditorWorkspaceVisiblePanels; +using XCEngine::NewEditor::FindUIEditorPanelSessionState; +using XCEngine::NewEditor::GetUIEditorShortcutDispatchStatusName; +using XCEngine::NewEditor::GetUIEditorWorkspaceCommandStatusName; +using XCEngine::NewEditor::UIEditorCommandPanelSource; +using XCEngine::NewEditor::UIEditorCommandRegistry; +using XCEngine::NewEditor::UIEditorPanelRegistry; +using XCEngine::NewEditor::UIEditorShortcutDispatchResult; +using XCEngine::NewEditor::UIEditorShortcutDispatchStatus; +using XCEngine::NewEditor::UIEditorShortcutManager; +using XCEngine::NewEditor::UIEditorWorkspaceCommandKind; +using XCEngine::NewEditor::UIEditorWorkspaceController; +using XCEngine::NewEditor::UIEditorWorkspaceModel; +using XCEngine::NewEditor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::UIShortcutBinding; +using XCEngine::UI::UIShortcutContext; +using XCEngine::UI::UIShortcutScope; +using XCEngine::XCUI::Host::AutoScreenshotController; +using XCEngine::XCUI::Host::InputModifierTracker; +using XCEngine::XCUI::Host::NativeRenderer; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorShortcutDispatchValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Shortcut Dispatch"; + +constexpr XCEngine::UI::UIElementId kWindowOwnerId = 1u; +constexpr XCEngine::UI::UIElementId kDocumentsPanelOwnerId = 11u; +constexpr XCEngine::UI::UIElementId kDetailsPanelOwnerId = 22u; + +constexpr UIColor kWindowBg(0.14f, 0.14f, 0.14f, 1.0f); +constexpr UIColor kCardBg(0.19f, 0.19f, 0.19f, 1.0f); +constexpr UIColor kCardBorder(0.29f, 0.29f, 0.29f, 1.0f); +constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f); +constexpr UIColor kTextMuted(0.70f, 0.70f, 0.70f, 1.0f); +constexpr UIColor kAccent(0.82f, 0.82f, 0.82f, 1.0f); +constexpr UIColor kSuccess(0.43f, 0.71f, 0.47f, 1.0f); +constexpr UIColor kWarning(0.78f, 0.60f, 0.30f, 1.0f); +constexpr UIColor kDanger(0.78f, 0.34f, 0.34f, 1.0f); + +enum class ActionId : unsigned char { + ToggleTextInput = 0, + ResetScenario +}; + +struct ButtonState { + ActionId action = ActionId::ToggleTextInput; + std::string label = {}; + UIRect rect = {}; +}; + +std::filesystem::path ResolveRepoRootPath() { + std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT; + if (root.size() >= 2u && root.front() == '"' && root.back() == '"') { + root = root.substr(1u, root.size() - 2u); + } + + return std::filesystem::path(root).lexically_normal(); +} + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.66f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +UIEditorCommandRegistry BuildCommandRegistry() { + UIEditorCommandRegistry registry = {}; + registry.commands = { + { + "workspace.hide_active", + "Hide Active", + { UIEditorWorkspaceCommandKind::HidePanel, UIEditorCommandPanelSource::ActivePanel, {} } + }, + { + "workspace.close_doc_b", + "Close Doc B", + { UIEditorWorkspaceCommandKind::ClosePanel, UIEditorCommandPanelSource::FixedPanelId, "doc-b" } + }, + { + "workspace.open_doc_b", + "Open Doc B", + { UIEditorWorkspaceCommandKind::OpenPanel, UIEditorCommandPanelSource::FixedPanelId, "doc-b" } + }, + { + "workspace.activate_details", + "Activate Details", + { UIEditorWorkspaceCommandKind::ActivatePanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.reset", + "Reset Workspace", + { UIEditorWorkspaceCommandKind::ResetWorkspace, UIEditorCommandPanelSource::None, {} } + } + }; + return registry; +} + +UIShortcutBinding MakeBinding( + std::string commandId, + UIShortcutScope scope, + XCEngine::UI::UIElementId ownerId, + KeyCode keyCode) { + UIShortcutBinding binding = {}; + binding.commandId = std::move(commandId); + binding.scope = scope; + binding.ownerId = ownerId; + binding.triggerEventType = UIInputEventType::KeyDown; + binding.chord.keyCode = static_cast(keyCode); + binding.chord.modifiers.control = true; + return binding; +} + +UIEditorShortcutManager BuildShortcutManager() { + UIEditorShortcutManager manager(BuildCommandRegistry()); + manager.RegisterBinding(MakeBinding("workspace.hide_active", UIShortcutScope::Global, 0u, KeyCode::H)); + manager.RegisterBinding(MakeBinding("workspace.close_doc_b", UIShortcutScope::Global, 0u, KeyCode::W)); + manager.RegisterBinding(MakeBinding("workspace.open_doc_b", UIShortcutScope::Global, 0u, KeyCode::O)); + manager.RegisterBinding(MakeBinding("workspace.reset", UIShortcutScope::Global, 0u, KeyCode::R)); + manager.RegisterBinding(MakeBinding("workspace.activate_details", UIShortcutScope::Panel, kDocumentsPanelOwnerId, KeyCode::P)); + manager.RegisterBinding(MakeBinding("workspace.reset", UIShortcutScope::Panel, kDetailsPanelOwnerId, KeyCode::P)); + return manager; +} + +std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) { + switch (wParam) { + case 'H': return static_cast(KeyCode::H); + case 'O': return static_cast(KeyCode::O); + case 'P': return static_cast(KeyCode::P); + case 'R': return static_cast(KeyCode::R); + case 'T': return static_cast(KeyCode::T); + case 'W': return static_cast(KeyCode::W); + case VK_CONTROL: return static_cast(KeyCode::LeftCtrl); + case VK_F12: return static_cast(KeyCode::F12); + default: return static_cast(KeyCode::None); + } +} + +bool ContainsPoint(const UIRect& rect, float x, float y) { + return x >= rect.x && x <= rect.x + rect.width && + y >= rect.y && y <= rect.y + rect.height; +} + +std::string JoinVisiblePanels(const UIEditorWorkspaceController& controller) { + const auto panels = CollectUIEditorWorkspaceVisiblePanels( + controller.GetWorkspace(), + controller.GetSession()); + if (panels.empty()) { + return "(none)"; + } + + std::ostringstream stream; + for (std::size_t index = 0; index < panels.size(); ++index) { + if (index > 0u) { + stream << ", "; + } + stream << panels[index].panelId; + } + return stream.str(); +} + +XCEngine::UI::UIElementId ResolveCurrentPanelOwnerId(std::string_view activePanelId) { + return activePanelId == "details" ? kDetailsPanelOwnerId : kDocumentsPanelOwnerId; +} + +std::string DescribeCurrentScope(std::string_view activePanelId) { + return activePanelId == "details" ? "Details Panel Scope" : "Documents Panel Scope"; +} + +UIColor ResolveShortcutStatusColor(UIEditorShortcutDispatchStatus status) { + switch (status) { + case UIEditorShortcutDispatchStatus::Dispatched: return kSuccess; + case UIEditorShortcutDispatchStatus::Suppressed: return kWarning; + case UIEditorShortcutDispatchStatus::Rejected: return kDanger; + case UIEditorShortcutDispatchStatus::NoMatch: + default: + return kTextMuted; + } +} + +UIColor ResolveCommandStatusColor(UIEditorWorkspaceController& controller, const UIEditorShortcutDispatchResult& result) { + (void)controller; + if (!result.commandExecuted) { + return kTextMuted; + } + + switch (result.commandResult.status) { + case XCEngine::NewEditor::UIEditorWorkspaceCommandStatus::Changed: return kSuccess; + case XCEngine::NewEditor::UIEditorWorkspaceCommandStatus::NoOp: return kWarning; + case XCEngine::NewEditor::UIEditorWorkspaceCommandStatus::Rejected: return kDanger; + } + + return kTextMuted; +} + +void DrawCard( + UIDrawList& drawList, + const UIRect& rect, + std::string_view title, + std::string_view subtitle = {}) { + drawList.AddFilledRect(rect, kCardBg, 12.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 12.0f); + drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 16.0f), std::string(title), kTextPrimary, 17.0f); + if (!subtitle.empty()) { + drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 42.0f), std::string(subtitle), kTextMuted, 12.0f); + } +} + +class ScenarioApp { +public: + int Run(HINSTANCE hInstance, int nCmdShow) { + if (!Initialize(hInstance, nCmdShow)) { + Shutdown(); + return 1; + } + + MSG message = {}; + while (message.message != WM_QUIT) { + if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + continue; + } + + RenderFrame(); + Sleep(8); + } + + Shutdown(); + return static_cast(message.wParam); + } + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + if (message == WM_NCCREATE) { + const auto* createStruct = reinterpret_cast(lParam); + auto* app = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(app)); + return TRUE; + } + + auto* app = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + switch (message) { + case WM_SIZE: + if (app != nullptr && wParam != SIZE_MINIMIZED) { + app->m_renderer.Resize(static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam))); + } + return 0; + case WM_PAINT: + if (app != nullptr) { + PAINTSTRUCT paintStruct = {}; + BeginPaint(hwnd, &paintStruct); + app->RenderFrame(); + EndPaint(hwnd, &paintStruct); + return 0; + } + break; + case WM_LBUTTONUP: + if (app != nullptr) { + app->HandleClick(static_cast(GET_X_LPARAM(lParam)), static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr) { + if (wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + } else { + app->HandleKey(UIInputEventType::KeyDown, wParam, lParam); + } + return 0; + } + break; + case WM_KEYUP: + case WM_SYSKEYUP: + if (app != nullptr) { + app->HandleKey(UIInputEventType::KeyUp, wParam, lParam); + return 0; + } + break; + case WM_ERASEBKGND: + return 1; + case WM_DESTROY: + PostQuitMessage(0); + return 0; + default: + break; + } + + return DefWindowProcW(hwnd, message, wParam, lParam); + } + + bool Initialize(HINSTANCE hInstance, int nCmdShow) { + m_hInstance = hInstance; + ResetScenario(); + + WNDCLASSEXW windowClass = {}; + windowClass.cbSize = sizeof(windowClass); + windowClass.style = CS_HREDRAW | CS_VREDRAW; + windowClass.lpfnWndProc = &ScenarioApp::WndProc; + windowClass.hInstance = hInstance; + windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW); + windowClass.lpszClassName = kWindowClassName; + m_windowClassAtom = RegisterClassExW(&windowClass); + if (m_windowClassAtom == 0) { + return false; + } + + m_hwnd = CreateWindowExW( + 0, + kWindowClassName, + kWindowTitle, + WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, + CW_USEDEFAULT, + 1340, + 860, + nullptr, + nullptr, + hInstance, + this); + if (m_hwnd == nullptr) { + return false; + } + + ShowWindow(m_hwnd, nCmdShow); + UpdateWindow(m_hwnd); + if (!m_renderer.Initialize(m_hwnd)) { + return false; + } + + m_autoScreenshot.Initialize( + (ResolveRepoRootPath() / "tests/UI/Editor/integration/state/shortcut_dispatch/captures").lexically_normal()); + return true; + } + + void Shutdown() { + m_autoScreenshot.Shutdown(); + m_renderer.Shutdown(); + if (m_hwnd != nullptr && IsWindow(m_hwnd)) { + DestroyWindow(m_hwnd); + } + if (m_windowClassAtom != 0 && m_hInstance != nullptr) { + UnregisterClassW(kWindowClassName, m_hInstance); + } + } + + void ResetScenario() { + m_controller = BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + m_shortcutManager = BuildShortcutManager(); + m_textInputActive = false; + m_inputModifierTracker.Reset(); + m_lastAction = "等待操作"; + m_lastShortcutStatus = "Pending"; + m_lastCommandStatus = "(none)"; + m_lastMessage = "先按 Ctrl+P,再看 Shortcut result 和 Command result 是不是分别变化。"; + m_lastShortcutColor = kTextMuted; + m_lastCommandColor = kTextMuted; + } + + UIShortcutContext BuildShortcutContext() const { + UIShortcutContext context = {}; + const auto panelOwnerId = ResolveCurrentPanelOwnerId(m_controller.GetWorkspace().activePanelId); + context.commandScope.path = { kWindowOwnerId, panelOwnerId }; + context.commandScope.windowId = kWindowOwnerId; + context.commandScope.panelId = panelOwnerId; + context.textInputActive = m_textInputActive; + return context; + } + + void HandleClick(float x, float y) { + for (const ButtonState& button : m_buttons) { + if (!ContainsPoint(button.rect, x, y)) { + continue; + } + + if (button.action == ActionId::ToggleTextInput) { + m_textInputActive = !m_textInputActive; + m_lastAction = "Toggle Text Input"; + m_lastShortcutStatus = "Changed"; + m_lastCommandStatus = "(none)"; + m_lastMessage = m_textInputActive + ? "text input active = true。现在按 Ctrl+H / Ctrl+P,Shortcut result 应为 Suppressed。" + : "text input active = false。快捷键恢复正常 dispatch。"; + m_lastShortcutColor = kSuccess; + m_lastCommandColor = kTextMuted; + } else { + ResetScenario(); + } + + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + } + + void HandleKey(UIInputEventType type, WPARAM wParam, LPARAM lParam) { + UIInputEvent event = {}; + event.type = type; + event.keyCode = MapVirtualKeyToUIKeyCode(wParam); + event.modifiers = m_inputModifierTracker.ApplyKeyMessage(type, wParam, lParam); + event.repeat = (static_cast(lParam) & 0x40000000u) != 0u; + + if (type == UIInputEventType::KeyUp || event.keyCode == static_cast(KeyCode::None)) { + return; + } + + if (event.keyCode == static_cast(KeyCode::T) && + !event.modifiers.control && + !event.modifiers.alt && + !event.modifiers.super) { + m_textInputActive = !m_textInputActive; + m_lastAction = "T"; + m_lastShortcutStatus = "Changed"; + m_lastCommandStatus = "(none)"; + m_lastMessage = m_textInputActive + ? "text input active = true。下一次 Ctrl 快捷键应被 Suppressed。" + : "text input active = false。下一次 Ctrl 快捷键应正常 dispatch。"; + m_lastShortcutColor = kSuccess; + m_lastCommandColor = kTextMuted; + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + if (event.keyCode == static_cast(KeyCode::LeftCtrl)) { + return; + } + + const UIEditorShortcutDispatchResult result = + m_shortcutManager.Dispatch(event, BuildShortcutContext(), m_controller); + m_lastAction = DescribeKey(event.keyCode); + m_lastShortcutStatus = std::string(GetUIEditorShortcutDispatchStatusName(result.status)); + m_lastShortcutColor = ResolveShortcutStatusColor(result.status); + if (result.commandExecuted) { + m_lastCommandStatus = std::string(GetUIEditorWorkspaceCommandStatusName(result.commandResult.status)); + m_lastCommandColor = ResolveCommandStatusColor(m_controller, result); + m_lastMessage = result.message + " | " + result.commandDisplayName + " -> " + result.commandResult.message; + } else { + m_lastCommandStatus = "(none)"; + m_lastCommandColor = kTextMuted; + m_lastMessage = result.message; + } + + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + std::string DescribeKey(std::int32_t keyCode) const { + if (keyCode == static_cast(KeyCode::H)) return "Ctrl+H"; + if (keyCode == static_cast(KeyCode::O)) return "Ctrl+O"; + if (keyCode == static_cast(KeyCode::P)) return "Ctrl+P"; + if (keyCode == static_cast(KeyCode::R)) return "Ctrl+R"; + if (keyCode == static_cast(KeyCode::W)) return "Ctrl+W"; + return "Key"; + } + + void RenderFrame() { + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(clientRect.right - clientRect.left, 1L)); + const float height = static_cast((std::max)(clientRect.bottom - clientRect.top, 1L)); + + const auto validation = m_controller.ValidateState(); + const auto shortcutValidation = m_shortcutManager.ValidateConfiguration(); + const auto shortcutContext = BuildShortcutContext(); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("Shortcut Dispatch"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + const float margin = 20.0f; + const UIRect headerRect(margin, margin, width - margin * 2.0f, 178.0f); + const UIRect leftRect(margin, headerRect.y + headerRect.height + 16.0f, 320.0f, height - 254.0f); + const UIRect rightRect(leftRect.x + leftRect.width + 16.0f, leftRect.y, width - leftRect.width - margin * 2.0f - 16.0f, height - 254.0f); + const UIRect footerRect(margin, height - 100.0f, width - margin * 2.0f, 80.0f); + + DrawCard(drawList, headerRect, "测试功能:Editor Shortcut Dispatch", "只验证 shortcut match -> editor command -> workspace command dispatch。"); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 70.0f), "1. 初始 active=doc-a,按 Ctrl+P,当前 documents scope 应命中 Activate Details。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 92.0f), "2. active=details 后再按 Ctrl+P,同一个 chord 应命中另一条 panel binding,并执行 Reset。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 114.0f), "3. 按 Ctrl+H,验证 ActivePanel 参数源;按 Ctrl+W / Ctrl+O,验证固定 panel 命令。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 136.0f), "4. 点 Toggle Text Input 或按 T,再按 Ctrl+H / Ctrl+P,Shortcut result 必须变成 Suppressed。", kTextPrimary, 13.0f); + drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 158.0f), "5. F12 保存截图。注意看 Footer 里 Shortcut result 和 Command result 是两层状态。", kTextPrimary, 13.0f); + + DrawCard(drawList, leftRect, "操作区", "这里只放辅助按钮;真正的验证入口是键盘快捷键。"); + DrawCard(drawList, rightRect, "状态摘要", "看当前 scope、text input、workspace 状态和 bindings。"); + DrawCard(drawList, footerRect, "最近结果", "Shortcut result 不等于 Command result。"); + + m_buttons = { + { ActionId::ToggleTextInput, m_textInputActive ? "Toggle Text Input: On" : "Toggle Text Input: Off", UIRect(leftRect.x + 18.0f, leftRect.y + 74.0f, leftRect.width - 36.0f, 46.0f) }, + { ActionId::ResetScenario, "Reset Scenario", UIRect(leftRect.x + 18.0f, leftRect.y + 132.0f, leftRect.width - 36.0f, 46.0f) } + }; + + for (const ButtonState& button : m_buttons) { + drawList.AddFilledRect(button.rect, kCardBorder, 8.0f); + drawList.AddRectOutline(button.rect, kTextMuted, 1.0f, 8.0f); + drawList.AddText(UIPoint(button.rect.x + 14.0f, button.rect.y + 13.0f), button.label, kTextPrimary, 13.0f); + } + + drawList.AddText(UIPoint(leftRect.x + 18.0f, leftRect.y + 210.0f), "Bindings", kAccent, 15.0f); + drawList.AddText(UIPoint(leftRect.x + 18.0f, leftRect.y + 236.0f), "Ctrl+P documents -> Activate Details", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(leftRect.x + 18.0f, leftRect.y + 256.0f), "Ctrl+P details -> Reset Workspace", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(leftRect.x + 18.0f, leftRect.y + 276.0f), "Ctrl+H global -> Hide Active", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(leftRect.x + 18.0f, leftRect.y + 296.0f), "Ctrl+W global -> Close Doc B", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(leftRect.x + 18.0f, leftRect.y + 316.0f), "Ctrl+O global -> Open Doc B", kTextPrimary, 12.0f); + drawList.AddText(UIPoint(leftRect.x + 18.0f, leftRect.y + 336.0f), "Ctrl+R global -> Reset Workspace", kTextPrimary, 12.0f); + + drawList.AddText(UIPoint(rightRect.x + 18.0f, rightRect.y + 74.0f), "Active panel: " + m_controller.GetWorkspace().activePanelId, kTextPrimary, 14.0f); + drawList.AddText(UIPoint(rightRect.x + 18.0f, rightRect.y + 98.0f), "Visible panels: " + JoinVisiblePanels(m_controller), kTextPrimary, 13.0f); + drawList.AddText(UIPoint(rightRect.x + 18.0f, rightRect.y + 122.0f), "Current panel scope: " + DescribeCurrentScope(m_controller.GetWorkspace().activePanelId), kTextPrimary, 13.0f); + drawList.AddText(UIPoint(rightRect.x + 18.0f, rightRect.y + 146.0f), "Panel owner id: " + std::to_string(shortcutContext.commandScope.panelId), kTextPrimary, 13.0f); + drawList.AddText(UIPoint(rightRect.x + 18.0f, rightRect.y + 170.0f), std::string("Text input active: ") + (m_textInputActive ? "true" : "false"), m_textInputActive ? kWarning : kSuccess, 13.0f); + drawList.AddText(UIPoint(rightRect.x + 18.0f, rightRect.y + 194.0f), validation.IsValid() ? "Workspace validation: OK" : "Workspace validation: " + validation.message, validation.IsValid() ? kSuccess : kDanger, 12.0f); + drawList.AddText(UIPoint(rightRect.x + 18.0f, rightRect.y + 216.0f), shortcutValidation.IsValid() ? "Shortcut manager validation: OK" : "Shortcut manager validation: " + shortcutValidation.message, shortcutValidation.IsValid() ? kSuccess : kDanger, 12.0f); + + const std::vector> panelDefs = { + { "doc-a", "Document A" }, + { "doc-b", "Document B" }, + { "details", "Details" } + }; + float rowY = rightRect.y + 260.0f; + for (const auto& [panelId, label] : panelDefs) { + const auto* state = FindUIEditorPanelSessionState(m_controller.GetSession(), panelId); + std::string stateText = state == nullptr + ? "missing" + : (!state->open ? "closed" : (!state->visible ? "hidden" : "visible")); + drawList.AddText( + UIPoint(rightRect.x + 18.0f, rowY), + label + ": " + stateText + (m_controller.GetWorkspace().activePanelId == panelId ? " | active" : ""), + ResolveCurrentPanelOwnerId(panelId) == shortcutContext.commandScope.panelId && panelId == "details" + ? kAccent + : (ResolveCurrentPanelOwnerId(panelId) == shortcutContext.commandScope.panelId && panelId != "details" ? kAccent : kTextPrimary), + 13.0f); + rowY += 22.0f; + } + + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 20.0f), "Last action: " + m_lastAction, kTextPrimary, 13.0f); + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 40.0f), "Shortcut result: " + m_lastShortcutStatus, m_lastShortcutColor, 12.0f); + drawList.AddText(UIPoint(footerRect.x + 260.0f, footerRect.y + 40.0f), "Command result: " + m_lastCommandStatus, m_lastCommandColor, 12.0f); + drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 60.0f), m_lastMessage, kTextPrimary, 12.0f); + + const std::string captureSummary = + m_autoScreenshot.HasPendingCapture() + ? "截图排队中..." + : (m_autoScreenshot.GetLastCaptureSummary().empty() + ? std::string("F12 -> tests/UI/Editor/integration/state/shortcut_dispatch/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText(UIPoint(footerRect.x + 870.0f, footerRect.y + 60.0f), captureSummary, kTextMuted, 12.0f); + + const bool framePresented = m_renderer.Render(drawData); + m_autoScreenshot.CaptureIfRequested( + m_renderer, + drawData, + static_cast(width), + static_cast(height), + framePresented); + } + + HWND m_hwnd = nullptr; + HINSTANCE m_hInstance = nullptr; + ATOM m_windowClassAtom = 0; + NativeRenderer m_renderer = {}; + AutoScreenshotController m_autoScreenshot = {}; + InputModifierTracker m_inputModifierTracker = {}; + UIEditorWorkspaceController m_controller = {}; + UIEditorShortcutManager m_shortcutManager = {}; + bool m_textInputActive = false; + std::vector m_buttons = {}; + std::string m_lastAction = {}; + std::string m_lastShortcutStatus = {}; + std::string m_lastCommandStatus = {}; + std::string m_lastMessage = {}; + UIColor m_lastShortcutColor = kTextMuted; + UIColor m_lastCommandColor = kTextMuted; +}; + +} // namespace + +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { + ScenarioApp app; + return app.Run(hInstance, nCmdShow); +} diff --git a/tests/UI/Editor/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index 98e1631c..9b69efdc 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -2,9 +2,13 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_editor_shell_asset_validation.cpp test_input_modifier_tracker.cpp test_structured_editor_shell.cpp + test_ui_editor_command_dispatcher.cpp + test_ui_editor_command_registry.cpp + test_ui_editor_menu_model.cpp test_ui_editor_panel_registry.cpp test_ui_editor_collection_primitives.cpp test_ui_editor_panel_chrome.cpp + test_ui_editor_shortcut_manager.cpp test_ui_editor_workspace_controller.cpp test_ui_editor_workspace_layout_persistence.cpp test_ui_editor_workspace_model.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_command_dispatcher.cpp b/tests/UI/Editor/unit/test_ui_editor_command_dispatcher.cpp new file mode 100644 index 00000000..d49f6047 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_command_dispatcher.cpp @@ -0,0 +1,118 @@ +#include + +#include + +namespace { + +using XCEngine::NewEditor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::NewEditor::BuildUIEditorWorkspacePanel; +using XCEngine::NewEditor::BuildUIEditorWorkspaceSplit; +using XCEngine::NewEditor::BuildUIEditorWorkspaceTabStack; +using XCEngine::NewEditor::GetUIEditorCommandDispatchStatusName; +using XCEngine::NewEditor::UIEditorCommandDispatchStatus; +using XCEngine::NewEditor::UIEditorCommandDispatcher; +using XCEngine::NewEditor::UIEditorCommandEvaluationCode; +using XCEngine::NewEditor::UIEditorCommandPanelSource; +using XCEngine::NewEditor::UIEditorCommandRegistry; +using XCEngine::NewEditor::UIEditorPanelRegistry; +using XCEngine::NewEditor::UIEditorWorkspaceCommandKind; +using XCEngine::NewEditor::UIEditorWorkspaceCommandStatus; +using XCEngine::NewEditor::UIEditorWorkspaceModel; +using XCEngine::NewEditor::UIEditorWorkspaceSplitAxis; + +UIEditorCommandRegistry BuildCommandRegistry() { + UIEditorCommandRegistry registry = {}; + registry.commands = { + { + "workspace.hide_active", + "Hide Active", + { UIEditorWorkspaceCommandKind::HidePanel, UIEditorCommandPanelSource::ActivePanel, {} } + }, + { + "workspace.activate_details", + "Activate Details", + { UIEditorWorkspaceCommandKind::ActivatePanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.reset", + "Reset Workspace", + { UIEditorWorkspaceCommandKind::ResetWorkspace, UIEditorCommandPanelSource::None, {} } + } + }; + return registry; +} + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.66f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +} // namespace + +TEST(UIEditorCommandDispatcherTest, EvaluateResolvesActivePanelCommandToCurrentWorkspaceTarget) { + UIEditorCommandDispatcher dispatcher(BuildCommandRegistry()); + const auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + + const auto evaluation = dispatcher.Evaluate("workspace.hide_active", controller); + + EXPECT_EQ(evaluation.code, UIEditorCommandEvaluationCode::None); + EXPECT_TRUE(evaluation.executable); + EXPECT_EQ(evaluation.commandId, "workspace.hide_active"); + EXPECT_EQ(evaluation.workspaceCommand.kind, UIEditorWorkspaceCommandKind::HidePanel); + EXPECT_EQ(evaluation.workspaceCommand.panelId, "doc-a"); + EXPECT_EQ(evaluation.previewResult.status, UIEditorWorkspaceCommandStatus::Changed); +} + +TEST(UIEditorCommandDispatcherTest, EvaluateRejectsActivePanelCommandWhenWorkspaceHasNoActivePanel) { + UIEditorCommandDispatcher dispatcher(BuildCommandRegistry()); + UIEditorWorkspaceModel workspace = BuildWorkspace(); + workspace.activePanelId.clear(); + + const auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), workspace); + + const auto evaluation = dispatcher.Evaluate("workspace.hide_active", controller); + + EXPECT_EQ(evaluation.code, UIEditorCommandEvaluationCode::MissingActivePanel); + EXPECT_FALSE(evaluation.executable); + EXPECT_EQ(evaluation.previewResult.status, UIEditorWorkspaceCommandStatus::Rejected); +} + +TEST(UIEditorCommandDispatcherTest, DispatchUsesResolvedWorkspaceCommandAndMutatesController) { + UIEditorCommandDispatcher dispatcher(BuildCommandRegistry()); + auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + + const auto result = dispatcher.Dispatch("workspace.activate_details", controller); + + EXPECT_EQ(result.status, UIEditorCommandDispatchStatus::Dispatched); + EXPECT_STREQ(GetUIEditorCommandDispatchStatusName(result.status).data(), "Dispatched"); + EXPECT_TRUE(result.commandExecuted); + EXPECT_EQ(result.commandId, "workspace.activate_details"); + EXPECT_EQ(result.commandResult.status, UIEditorWorkspaceCommandStatus::Changed); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "details"); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_command_registry.cpp b/tests/UI/Editor/unit/test_ui_editor_command_registry.cpp new file mode 100644 index 00000000..94cb3866 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_command_registry.cpp @@ -0,0 +1,75 @@ +#include + +#include + +namespace { + +using XCEngine::NewEditor::FindUIEditorCommandDescriptor; +using XCEngine::NewEditor::UIEditorCommandDescriptor; +using XCEngine::NewEditor::UIEditorCommandPanelSource; +using XCEngine::NewEditor::UIEditorCommandRegistry; +using XCEngine::NewEditor::UIEditorCommandRegistryValidationCode; +using XCEngine::NewEditor::UIEditorWorkspaceCommandKind; +using XCEngine::NewEditor::ValidateUIEditorCommandRegistry; + +UIEditorCommandRegistry BuildCommandRegistry() { + UIEditorCommandRegistry registry = {}; + registry.commands = { + { + "workspace.hide_active", + "Hide Active", + { UIEditorWorkspaceCommandKind::HidePanel, UIEditorCommandPanelSource::ActivePanel, {} } + }, + { + "workspace.activate_details", + "Activate Details", + { UIEditorWorkspaceCommandKind::ActivatePanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.reset", + "Reset Workspace", + { UIEditorWorkspaceCommandKind::ResetWorkspace, UIEditorCommandPanelSource::None, {} } + } + }; + return registry; +} + +} // namespace + +TEST(UIEditorCommandRegistryTest, FindDescriptorReturnsRegisteredCommand) { + const UIEditorCommandRegistry registry = BuildCommandRegistry(); + + const UIEditorCommandDescriptor* descriptor = + FindUIEditorCommandDescriptor(registry, "workspace.activate_details"); + + ASSERT_NE(descriptor, nullptr); + EXPECT_EQ(descriptor->displayName, "Activate Details"); +} + +TEST(UIEditorCommandRegistryTest, ValidationRejectsDuplicateCommandIdsAndMissingDisplayName) { + UIEditorCommandRegistry registry = BuildCommandRegistry(); + registry.commands.push_back(registry.commands.front()); + + auto validation = ValidateUIEditorCommandRegistry(registry); + EXPECT_EQ(validation.code, UIEditorCommandRegistryValidationCode::DuplicateCommandId); + + registry = BuildCommandRegistry(); + registry.commands[1].displayName.clear(); + validation = ValidateUIEditorCommandRegistry(registry); + EXPECT_EQ(validation.code, UIEditorCommandRegistryValidationCode::EmptyDisplayName); +} + +TEST(UIEditorCommandRegistryTest, ValidationRejectsInvalidPanelSourceUsage) { + UIEditorCommandRegistry registry = BuildCommandRegistry(); + registry.commands[0].workspaceCommand.panelSource = UIEditorCommandPanelSource::None; + + auto validation = ValidateUIEditorCommandRegistry(registry); + EXPECT_EQ(validation.code, UIEditorCommandRegistryValidationCode::MissingPanelSource); + + registry = BuildCommandRegistry(); + registry.commands[2].workspaceCommand.panelSource = + UIEditorCommandPanelSource::FixedPanelId; + registry.commands[2].workspaceCommand.panelId = "details"; + validation = ValidateUIEditorCommandRegistry(registry); + EXPECT_EQ(validation.code, UIEditorCommandRegistryValidationCode::UnexpectedPanelSource); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_menu_model.cpp b/tests/UI/Editor/unit/test_ui_editor_menu_model.cpp new file mode 100644 index 00000000..91230a09 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_menu_model.cpp @@ -0,0 +1,223 @@ +#include + +#include +#include + +#include + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::NewEditor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::NewEditor::BuildUIEditorWorkspacePanel; +using XCEngine::NewEditor::BuildUIEditorWorkspaceSplit; +using XCEngine::NewEditor::BuildUIEditorWorkspaceTabStack; +using XCEngine::NewEditor::BuildUIEditorResolvedMenuModel; +using XCEngine::NewEditor::UIEditorCommandDispatcher; +using XCEngine::NewEditor::UIEditorCommandPanelSource; +using XCEngine::NewEditor::UIEditorCommandRegistry; +using XCEngine::NewEditor::UIEditorMenuCheckedStateSource; +using XCEngine::NewEditor::UIEditorMenuItemDescriptor; +using XCEngine::NewEditor::UIEditorMenuItemKind; +using XCEngine::NewEditor::UIEditorMenuModel; +using XCEngine::NewEditor::UIEditorMenuModelValidationCode; +using XCEngine::NewEditor::UIEditorPanelRegistry; +using XCEngine::NewEditor::UIEditorShortcutManager; +using XCEngine::NewEditor::UIEditorWorkspaceCommandKind; +using XCEngine::NewEditor::UIEditorWorkspaceCommandStatus; +using XCEngine::NewEditor::UIEditorWorkspaceModel; +using XCEngine::NewEditor::UIEditorWorkspaceSplitAxis; +using XCEngine::NewEditor::ValidateUIEditorMenuModel; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIShortcutBinding; +using XCEngine::UI::UIShortcutScope; + +UIEditorCommandRegistry BuildCommandRegistry() { + UIEditorCommandRegistry registry = {}; + registry.commands = { + { + "workspace.show_details", + "Show Details", + { UIEditorWorkspaceCommandKind::ShowPanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.hide_active", + "Hide Active", + { UIEditorWorkspaceCommandKind::HidePanel, UIEditorCommandPanelSource::ActivePanel, {} } + }, + { + "workspace.reset", + "Reset Workspace", + { UIEditorWorkspaceCommandKind::ResetWorkspace, UIEditorCommandPanelSource::None, {} } + } + }; + return registry; +} + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.66f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +UIShortcutBinding MakeBinding( + std::string commandId, + UIShortcutScope scope, + XCEngine::UI::UIElementId ownerId, + KeyCode keyCode) { + UIShortcutBinding binding = {}; + binding.commandId = std::move(commandId); + binding.scope = scope; + binding.ownerId = ownerId; + binding.triggerEventType = UIInputEventType::KeyDown; + binding.chord.keyCode = static_cast(keyCode); + binding.chord.modifiers.control = true; + return binding; +} + +UIEditorMenuModel BuildMenuModel() { + UIEditorMenuModel model = {}; + model.menus = { + { + "window", + "Window", + { + { + UIEditorMenuItemKind::Command, + "show-details", + {}, + "workspace.show_details", + { UIEditorMenuCheckedStateSource::PanelVisible, "details" }, + {} + }, + { + UIEditorMenuItemKind::Separator, + "separator-1", + {}, + {}, + {}, + {} + }, + { + UIEditorMenuItemKind::Submenu, + "layout", + "Layout", + {}, + {}, + { + { + UIEditorMenuItemKind::Command, + "reset-layout", + {}, + "workspace.reset", + {}, + {} + } + } + } + } + } + }; + return model; +} + +} // namespace + +TEST(UIEditorMenuModelTest, ValidationRejectsUnknownCommandAndInvalidSubmenuShape) { + UIEditorMenuModel model = BuildMenuModel(); + model.menus[0].items[0].commandId = "missing.command"; + + auto validation = ValidateUIEditorMenuModel(model, BuildCommandRegistry()); + EXPECT_EQ(validation.code, UIEditorMenuModelValidationCode::UnknownCommandId); + + model = BuildMenuModel(); + model.menus[0].items[2].children.clear(); + validation = ValidateUIEditorMenuModel(model, BuildCommandRegistry()); + EXPECT_EQ(validation.code, UIEditorMenuModelValidationCode::SubmenuEmptyChildren); +} + +TEST(UIEditorMenuModelTest, ResolvedModelUsesCommandDisplayNameShortcutTextAndCheckedState) { + UIEditorShortcutManager shortcutManager(BuildCommandRegistry()); + shortcutManager.RegisterBinding( + MakeBinding("workspace.reset", UIShortcutScope::Panel, 22u, KeyCode::P)); + shortcutManager.RegisterBinding( + MakeBinding("workspace.reset", UIShortcutScope::Global, 0u, KeyCode::R)); + + const auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + const UIEditorCommandDispatcher dispatcher(BuildCommandRegistry()); + + const auto resolved = BuildUIEditorResolvedMenuModel( + BuildMenuModel(), + dispatcher, + controller, + &shortcutManager); + + ASSERT_EQ(resolved.menus.size(), 1u); + ASSERT_EQ(resolved.menus[0].items.size(), 3u); + EXPECT_EQ(resolved.menus[0].items[0].label, "Show Details"); + EXPECT_TRUE(resolved.menus[0].items[0].checked); + + const auto& submenu = resolved.menus[0].items[2]; + ASSERT_EQ(submenu.children.size(), 1u); + EXPECT_EQ(submenu.children[0].label, "Reset Workspace"); + EXPECT_EQ(submenu.children[0].shortcutText, "Ctrl+R"); + EXPECT_TRUE(submenu.children[0].enabled); +} + +TEST(UIEditorMenuModelTest, ResolvedModelDisablesCommandWhenEvaluationCannotResolveActivePanel) { + UIEditorMenuModel model = {}; + model.menus = { + { + "window", + "Window", + { + { + UIEditorMenuItemKind::Command, + "hide-active", + {}, + "workspace.hide_active", + {}, + {} + } + } + } + }; + + UIEditorWorkspaceModel workspace = BuildWorkspace(); + workspace.activePanelId.clear(); + const auto controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), workspace); + const UIEditorCommandDispatcher dispatcher(BuildCommandRegistry()); + + const auto resolved = BuildUIEditorResolvedMenuModel(model, dispatcher, controller); + + ASSERT_EQ(resolved.menus.size(), 1u); + ASSERT_EQ(resolved.menus[0].items.size(), 1u); + EXPECT_FALSE(resolved.menus[0].items[0].enabled); + EXPECT_EQ( + resolved.menus[0].items[0].previewStatus, + UIEditorWorkspaceCommandStatus::Rejected); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_shortcut_manager.cpp b/tests/UI/Editor/unit/test_ui_editor_shortcut_manager.cpp new file mode 100644 index 00000000..23989db7 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_shortcut_manager.cpp @@ -0,0 +1,194 @@ +#include + +#include + +#include + +namespace { + +using XCEngine::Input::KeyCode; +using XCEngine::NewEditor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::NewEditor::BuildUIEditorWorkspacePanel; +using XCEngine::NewEditor::BuildUIEditorWorkspaceSplit; +using XCEngine::NewEditor::BuildUIEditorWorkspaceTabStack; +using XCEngine::NewEditor::UIEditorCommandPanelSource; +using XCEngine::NewEditor::UIEditorCommandRegistry; +using XCEngine::NewEditor::UIEditorShortcutDispatchStatus; +using XCEngine::NewEditor::UIEditorShortcutManager; +using XCEngine::NewEditor::UIEditorShortcutManagerValidationCode; +using XCEngine::NewEditor::UIEditorWorkspaceCommandKind; +using XCEngine::NewEditor::UIEditorWorkspaceCommandStatus; +using XCEngine::NewEditor::UIEditorWorkspaceModel; +using XCEngine::NewEditor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::UIInputEvent; +using XCEngine::UI::UIInputEventType; +using XCEngine::UI::UIShortcutBinding; +using XCEngine::UI::UIShortcutContext; +using XCEngine::UI::UIShortcutScope; + +UIEditorCommandRegistry BuildCommandRegistry() { + UIEditorCommandRegistry registry = {}; + registry.commands = { + { + "workspace.hide_active", + "Hide Active", + { UIEditorWorkspaceCommandKind::HidePanel, UIEditorCommandPanelSource::ActivePanel, {} } + }, + { + "workspace.activate_details", + "Activate Details", + { UIEditorWorkspaceCommandKind::ActivatePanel, UIEditorCommandPanelSource::FixedPanelId, "details" } + }, + { + "workspace.reset", + "Reset Workspace", + { UIEditorWorkspaceCommandKind::ResetWorkspace, UIEditorCommandPanelSource::None, {} } + } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.66f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 0u), + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +UIShortcutBinding MakeBinding( + std::string commandId, + UIShortcutScope scope, + XCEngine::UI::UIElementId ownerId, + KeyCode keyCode, + bool control = true) { + UIShortcutBinding binding = {}; + binding.commandId = std::move(commandId); + binding.scope = scope; + binding.ownerId = ownerId; + binding.triggerEventType = UIInputEventType::KeyDown; + binding.chord.keyCode = static_cast(keyCode); + binding.chord.modifiers.control = control; + return binding; +} + +UIInputEvent MakeCtrlKeyEvent(KeyCode keyCode) { + UIInputEvent event = {}; + event.type = UIInputEventType::KeyDown; + event.keyCode = static_cast(keyCode); + event.modifiers.control = true; + return event; +} + +UIShortcutContext BuildPanelShortcutContext(XCEngine::UI::UIElementId panelOwnerId) { + UIShortcutContext context = {}; + context.commandScope.path = { 10u, panelOwnerId, 30u }; + context.commandScope.windowId = 10u; + context.commandScope.panelId = panelOwnerId; + context.commandScope.widgetId = 30u; + return context; +} + +} // namespace + +TEST(UIEditorShortcutManagerTest, ValidationRejectsUnknownCommandAndConflictingChord) { + UIEditorShortcutManager manager(BuildCommandRegistry()); + manager.RegisterBinding(MakeBinding("missing.command", UIShortcutScope::Global, 0u, KeyCode::H)); + + auto validation = manager.ValidateConfiguration(); + EXPECT_EQ(validation.code, UIEditorShortcutManagerValidationCode::UnknownCommandId); + + manager = UIEditorShortcutManager(BuildCommandRegistry()); + manager.RegisterBinding(MakeBinding("workspace.hide_active", UIShortcutScope::Global, 0u, KeyCode::H)); + manager.RegisterBinding(MakeBinding("workspace.reset", UIShortcutScope::Global, 0u, KeyCode::H)); + validation = manager.ValidateConfiguration(); + EXPECT_EQ(validation.code, UIEditorShortcutManagerValidationCode::ConflictingBinding); +} + +TEST(UIEditorShortcutManagerTest, DispatchUsesActivePanelSourceForWorkspaceCommand) { + UIEditorShortcutManager manager(BuildCommandRegistry()); + manager.RegisterBinding(MakeBinding("workspace.hide_active", UIShortcutScope::Global, 0u, KeyCode::H)); + + auto controller = + BuildDefaultUIEditorWorkspaceController( + XCEngine::NewEditor::UIEditorPanelRegistry{ + { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + } + }, + BuildWorkspace()); + + const auto result = + manager.Dispatch(MakeCtrlKeyEvent(KeyCode::H), {}, controller); + + EXPECT_EQ(result.status, UIEditorShortcutDispatchStatus::Dispatched); + EXPECT_TRUE(result.commandExecuted); + EXPECT_EQ(result.commandId, "workspace.hide_active"); + EXPECT_EQ(result.commandResult.status, UIEditorWorkspaceCommandStatus::Changed); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-b"); +} + +TEST(UIEditorShortcutManagerTest, DispatchPrefersPanelScopeBindingOverGlobalBinding) { + UIEditorShortcutManager manager(BuildCommandRegistry()); + manager.RegisterBinding(MakeBinding("workspace.reset", UIShortcutScope::Global, 0u, KeyCode::P)); + manager.RegisterBinding(MakeBinding("workspace.activate_details", UIShortcutScope::Panel, 20u, KeyCode::P)); + + auto controller = + BuildDefaultUIEditorWorkspaceController( + XCEngine::NewEditor::UIEditorPanelRegistry{ + { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + } + }, + BuildWorkspace()); + + const auto result = manager.Dispatch( + MakeCtrlKeyEvent(KeyCode::P), + BuildPanelShortcutContext(20u), + controller); + + EXPECT_EQ(result.status, UIEditorShortcutDispatchStatus::Dispatched); + EXPECT_EQ(result.commandId, "workspace.activate_details"); + EXPECT_EQ(result.commandResult.status, UIEditorWorkspaceCommandStatus::Changed); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "details"); +} + +TEST(UIEditorShortcutManagerTest, DispatchSuppressesMatchedShortcutWhenTextInputIsActive) { + UIEditorShortcutManager manager(BuildCommandRegistry()); + manager.RegisterBinding(MakeBinding("workspace.hide_active", UIShortcutScope::Global, 0u, KeyCode::H)); + + auto controller = + BuildDefaultUIEditorWorkspaceController( + XCEngine::NewEditor::UIEditorPanelRegistry{ + { + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "details", "Details", {}, true, true, true } + } + }, + BuildWorkspace()); + + UIShortcutContext context = {}; + context.textInputActive = true; + + const auto result = + manager.Dispatch(MakeCtrlKeyEvent(KeyCode::H), context, controller); + + EXPECT_EQ(result.status, UIEditorShortcutDispatchStatus::Suppressed); + EXPECT_FALSE(result.commandExecuted); + EXPECT_EQ(controller.GetWorkspace().activePanelId, "doc-a"); +}