From 68c4c80b06e3afa213db7a79ece3a3f1a5494259 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sun, 5 Apr 2026 12:10:55 +0800 Subject: [PATCH] Add XCUI command routing and widget state models --- docs/plan/XCUI_Phase_Status_2026-04-05.md | 20 +- engine/CMakeLists.txt | 4 + .../UI/Widgets/UIKeyboardNavigationModel.h | 40 ++++ .../XCEngine/UI/Widgets/UIPropertyEditModel.h | 32 +++ .../UI/Widgets/UIKeyboardNavigationModel.cpp | 162 ++++++++++++++ engine/src/UI/Widgets/UIPropertyEditModel.cpp | 95 ++++++++ .../src/XCUIBackend/XCUIDemoRuntime.cpp | 25 ++- new_editor/src/XCUIBackend/XCUIDemoRuntime.h | 1 + .../XCUIBackend/XCUIEditorCommandRouter.cpp | 210 ++++++++++++++++++ .../src/XCUIBackend/XCUIEditorCommandRouter.h | 101 +++++++++ tests/Core/UI/CMakeLists.txt | 2 + .../UI/test_ui_keyboard_navigation_model.cpp | 101 +++++++++ tests/Core/UI/test_ui_property_edit_model.cpp | 80 +++++++ tests/NewEditor/CMakeLists.txt | 33 +++ .../test_imgui_window_ui_compositor.cpp | 50 +++++ tests/NewEditor/test_xcui_demo_runtime.cpp | 94 ++++++++ .../test_xcui_editor_command_router.cpp | 205 +++++++++++++++++ .../test_xcui_hosted_preview_presenter.cpp | 83 +++++++ 18 files changed, 1329 insertions(+), 9 deletions(-) create mode 100644 engine/include/XCEngine/UI/Widgets/UIKeyboardNavigationModel.h create mode 100644 engine/include/XCEngine/UI/Widgets/UIPropertyEditModel.h create mode 100644 engine/src/UI/Widgets/UIKeyboardNavigationModel.cpp create mode 100644 engine/src/UI/Widgets/UIPropertyEditModel.cpp create mode 100644 new_editor/src/XCUIBackend/XCUIEditorCommandRouter.cpp create mode 100644 new_editor/src/XCUIBackend/XCUIEditorCommandRouter.h create mode 100644 tests/Core/UI/test_ui_keyboard_navigation_model.cpp create mode 100644 tests/Core/UI/test_ui_property_edit_model.cpp create mode 100644 tests/NewEditor/test_xcui_editor_command_router.cpp diff --git a/docs/plan/XCUI_Phase_Status_2026-04-05.md b/docs/plan/XCUI_Phase_Status_2026-04-05.md index 1af40f86..732f5d60 100644 --- a/docs/plan/XCUI_Phase_Status_2026-04-05.md +++ b/docs/plan/XCUI_Phase_Status_2026-04-05.md @@ -34,13 +34,15 @@ Old `editor` replacement is explicitly out of scope for this phase. - Shared editor collection primitive classification and metric helpers now also live under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets`, covering the current `ScrollView` / `TreeView` / `ListView` / `PropertySection` / `FieldRow` prototype taxonomy. - Shared single-selection state now also lives under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets` as `UISelectionModel`, so collection-style widget selection no longer has to stay private to `LayoutLab`. - Shared expansion state now also lives under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets` as `UIExpansionModel`, so collapsible tree/property-style widget state no longer has to stay private to `LayoutLab`. +- Shared keyboard-navigation state now also lives under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets` as `UIKeyboardNavigationModel`, so list/tree/property-style widgets can share current-index, anchor, and home/end/step navigation rules instead of re-rolling them per sandbox. +- Shared property-edit session state now also lives under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets` as `UIPropertyEditModel`, so editor-facing field rows can reuse begin/edit/commit/cancel transaction state instead of baking that directly into sandbox runtimes. - Core regression coverage now includes `UIContext`, layout, style, runtime screen player/system, and real document-host tests through `core_ui_tests`. Current gap: - Minimal schema self-definition support is landed, including consistency checks for enum/document-only schema metadata, but schema-driven validation for `.xcui` / `.xctheme` instances is still not implemented. - Shared widget/runtime instantiation is still thin and mostly editor-side. -- Common widget primitives are still incomplete: shared text-input presentation/composition on top of the new text controller, multi-selection/keyboard-navigation/virtualized collection state on top of the new editor-primitive helpers, and native image/source-rect level APIs. +- Common widget primitives are still incomplete: shared text-input presentation/composition on top of the new text controller, multi-selection/focus-traversal/virtualized collection state on top of the new editor-primitive helpers, and native image/source-rect level APIs. ### 2. Runtime/Game Layer @@ -70,6 +72,8 @@ Current gap: - `LayoutLab` now consumes the shared `UIEditorCollectionPrimitives` helper layer for collection-widget tag classification, clipping flags, and default metric resolution instead of keeping that taxonomy private to the sandbox runtime. - `LayoutLab` now also consumes the shared `UISelectionModel` for click-selection persistence across collection-style widgets, and the diagnostics panel now exposes both hovered and selected element ids. - `LayoutLab` now also consumes the shared `UIExpansionModel` for tree expansion and property-section collapse, with reserved property headers, disclosure glyphs, and persisted click-toggle behavior in the sandbox runtime. +- `new_editor` now also has an isolated `XCUIEditorCommandRouter` model with shortcut matching, enable predicates, and direct command invocation semantics covered by dedicated tests, ready for shell-frame integration. +- `XCUI Demo` now exports pending per-frame command ids through `DrainPendingCommandIds()`, so editor-side hosts have a clean seam for observing demo/runtime command traffic without parsing draw data. - Panel diagnostics were expanded to clearly separate preview/runtime/input state and native vs legacy paths. - The editor bridge layer now has smoke coverage for swapchain after-UI rendering hooks and SRV-backed ImGui texture descriptor registration. - `Application` no longer owns the ImGui backend directly; window presentation now routes through `IWindowUICompositor` with an `ImGuiWindowUICompositor` implementation, which currently delegates to `IEditorHostCompositor` / `ImGuiHostCompositor`. @@ -82,7 +86,7 @@ Current gap: - The shell is still ImGui-hosted. - Legacy hosted preview still depends on an active ImGui window context for inline presentation. -- Editor-specialized widgets are still incomplete at the shared-module level: the authored prototypes exist, but virtualization, multi-selection/keyboard-navigation state, command routing, property editing models, toolbar/menu chrome, and icon-atlas widgets are not yet extracted into reusable XCUI modules. +- Editor-specialized widgets are still incomplete at the shared-module level: the authored prototypes exist, but virtualization, multi-selection/focus traversal, toolbar/menu chrome, and icon-atlas widgets are not yet extracted into reusable XCUI modules, and the new keyboard-navigation/property-edit/command-routing models are still only partially integrated. ## Validated This Phase @@ -90,10 +94,11 @@ Current gap: - `new_editor_xcui_layout_lab_runtime_tests`: `9/9` - `new_editor_xcui_rhi_command_compiler_tests`: `6/6` - `new_editor_xcui_rhi_render_backend_tests`: `5/5` -- `new_editor_xcui_hosted_preview_presenter_tests`: `12/12` -- `new_editor_imgui_window_ui_compositor_tests`: `5/5` +- `new_editor_xcui_hosted_preview_presenter_tests`: `14/14` +- `new_editor_imgui_window_ui_compositor_tests`: `7/7` +- `new_editor_xcui_editor_command_router_tests`: `5/5` - `XCNewEditor` Debug target builds successfully -- `core_ui_tests`: `38 total` (`36` passed, `2` skipped because `KeyCode::Delete` currently aliases `Backspace`) +- `core_ui_tests`: `49 total` (`47` passed, `2` skipped because `KeyCode::Delete` currently aliases `Backspace`) - `scene_tests`: `68/68` - `core_ui_style_tests`: `5/5` - `ui_resource_tests`: `11/11` @@ -108,6 +113,8 @@ Current gap: - Common-core `UIEditorCollectionPrimitives` extraction now owns the editor collection tag taxonomy and default metric resolution used by current `LayoutLab` widget prototypes, with dedicated `core_ui_tests` coverage. - Common-core `UISelectionModel` extraction now owns reusable single-selection state for collection-style widgets, with dedicated `core_ui_tests` coverage. - Common-core `UIExpansionModel` extraction now owns reusable expansion/collapse state for tree/property-style widgets, with dedicated `core_ui_tests` coverage. +- Common-core `UIKeyboardNavigationModel` extraction now owns reusable current-index/anchor navigation state for collection-style widgets, with dedicated `core_ui_tests` coverage. +- Common-core `UIPropertyEditModel` extraction now owns reusable property-field edit session state, including staged values and commit/cancel behavior, with dedicated `core_ui_tests` coverage. - Demo runtime text editing was extended with: - click-to-place caret - `Delete` support @@ -118,6 +125,7 @@ Current gap: - LayoutLab editor-widget prototypes for tree/list/property-style sections with dedicated runtime coverage. - LayoutLab click-selection now persists through the shared `UISelectionModel`, including selected-state diagnostics and reusable visual selection feedback on cards, collection rows, and field rows. - LayoutLab tree expansion and property-section collapse now persist through the shared `UIExpansionModel`, including reserved property headers, disclosure glyphs, and runtime coverage for collapsed/expanded visibility. +- `XCUIDemoRuntime` now exposes `DrainPendingCommandIds()` so hosts can observe emitted runtime commands in order across pointer/text interactions without scraping UI text or draw-command payloads. - Schema document support extended with: - retained `UISchemaDefinition` data on `UIDocumentModel` - artifact schema version bump for UI documents @@ -162,6 +170,8 @@ Current gap: - `ImGuiHostCompositor` - `Application` frame/present flow routed through the compositor instead of direct `m_imguiBackend` ownership - The window-level XCUI compositor seam now also has a dedicated regression target around `ImGuiWindowUICompositor`, covering initialization, render-frame ordering, Win32 message forwarding, texture registration forwarding, and shutdown safety. +- The window compositor and hosted-preview seams gained more edge-case coverage around no-UI render passes, compositor re-initialization/rebinding, partial logical-size fallback, and descriptor reuse for repeated queued-frame keys. +- `new_editor` now has a dedicated `XCUIEditorCommandRouter` test target covering command registration, replacement, enable predicates, accelerator matching, and policy gates around focus/keyboard capture/text input. - `SceneRuntime` layered XCUI routing now has dedicated regression coverage for: - top-interactive layer input ownership - blocking/modal layer suppression of lower layers diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index d20d97ba..a8fcd671 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -522,9 +522,13 @@ add_library(XCEngine STATIC ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextInputController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIExpansionModel.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIKeyboardNavigationModel.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIPropertyEditModel.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UISelectionModel.h ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIEditorCollectionPrimitives.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIExpansionModel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIKeyboardNavigationModel.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIPropertyEditModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UISelectionModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenTypes.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h diff --git a/engine/include/XCEngine/UI/Widgets/UIKeyboardNavigationModel.h b/engine/include/XCEngine/UI/Widgets/UIKeyboardNavigationModel.h new file mode 100644 index 00000000..de11ea8a --- /dev/null +++ b/engine/include/XCEngine/UI/Widgets/UIKeyboardNavigationModel.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +class UIKeyboardNavigationModel { +public: + static constexpr std::size_t InvalidIndex = static_cast(-1); + + std::size_t GetItemCount() const; + bool SetItemCount(std::size_t itemCount); + bool ClampToItemCount(); + + bool HasCurrentIndex() const; + std::size_t GetCurrentIndex() const; + bool SetCurrentIndex(std::size_t index, bool updateAnchor = true); + bool ClearCurrentIndex(); + + bool HasSelectionAnchor() const; + std::size_t GetSelectionAnchorIndex() const; + bool SetSelectionAnchorIndex(std::size_t index); + bool ClearSelectionAnchor(); + + bool MoveNext(); + bool MovePrevious(); + bool MoveHome(); + bool MoveEnd(); + +private: + std::size_t m_itemCount = 0; + std::size_t m_currentIndex = InvalidIndex; + std::size_t m_selectionAnchorIndex = InvalidIndex; +}; + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Widgets/UIPropertyEditModel.h b/engine/include/XCEngine/UI/Widgets/UIPropertyEditModel.h new file mode 100644 index 00000000..48a1c91e --- /dev/null +++ b/engine/include/XCEngine/UI/Widgets/UIPropertyEditModel.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +class UIPropertyEditModel { +public: + bool HasActiveEdit() const; + const std::string& GetActiveFieldId() const; + const std::string& GetStagedValue() const; + bool IsDirty() const; + + bool BeginEdit(std::string fieldId, std::string initialValue); + bool UpdateStagedValue(std::string stagedValue); + bool CommitEdit( + std::string* outFieldId = nullptr, + std::string* outCommittedValue = nullptr); + bool CancelEdit(); + +private: + std::string m_activeFieldId = {}; + std::string m_baselineValue = {}; + std::string m_stagedValue = {}; + bool m_dirty = false; +}; + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Widgets/UIKeyboardNavigationModel.cpp b/engine/src/UI/Widgets/UIKeyboardNavigationModel.cpp new file mode 100644 index 00000000..7cfadb7d --- /dev/null +++ b/engine/src/UI/Widgets/UIKeyboardNavigationModel.cpp @@ -0,0 +1,162 @@ +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +namespace { + +bool ClampIndexToCount(std::size_t itemCount, std::size_t& index) { + if (index == UIKeyboardNavigationModel::InvalidIndex) { + return false; + } + + if (itemCount == 0u) { + index = UIKeyboardNavigationModel::InvalidIndex; + return true; + } + + if (index < itemCount) { + return false; + } + + index = itemCount - 1u; + return true; +} + +} // namespace + +std::size_t UIKeyboardNavigationModel::GetItemCount() const { + return m_itemCount; +} + +bool UIKeyboardNavigationModel::SetItemCount(std::size_t itemCount) { + const bool countChanged = m_itemCount != itemCount; + m_itemCount = itemCount; + return ClampToItemCount() || countChanged; +} + +bool UIKeyboardNavigationModel::ClampToItemCount() { + bool changed = false; + changed = ClampIndexToCount(m_itemCount, m_currentIndex) || changed; + changed = ClampIndexToCount(m_itemCount, m_selectionAnchorIndex) || changed; + return changed; +} + +bool UIKeyboardNavigationModel::HasCurrentIndex() const { + return m_currentIndex != InvalidIndex; +} + +std::size_t UIKeyboardNavigationModel::GetCurrentIndex() const { + return m_currentIndex; +} + +bool UIKeyboardNavigationModel::SetCurrentIndex(std::size_t index, bool updateAnchor) { + if (index >= m_itemCount) { + return false; + } + + bool changed = false; + if (m_currentIndex != index) { + m_currentIndex = index; + changed = true; + } + + if (updateAnchor && m_selectionAnchorIndex != index) { + m_selectionAnchorIndex = index; + changed = true; + } + + return changed; +} + +bool UIKeyboardNavigationModel::ClearCurrentIndex() { + if (m_currentIndex == InvalidIndex) { + return false; + } + + m_currentIndex = InvalidIndex; + return true; +} + +bool UIKeyboardNavigationModel::HasSelectionAnchor() const { + return m_selectionAnchorIndex != InvalidIndex; +} + +std::size_t UIKeyboardNavigationModel::GetSelectionAnchorIndex() const { + return m_selectionAnchorIndex; +} + +bool UIKeyboardNavigationModel::SetSelectionAnchorIndex(std::size_t index) { + if (index >= m_itemCount) { + return false; + } + + if (m_selectionAnchorIndex == index) { + return false; + } + + m_selectionAnchorIndex = index; + return true; +} + +bool UIKeyboardNavigationModel::ClearSelectionAnchor() { + if (m_selectionAnchorIndex == InvalidIndex) { + return false; + } + + m_selectionAnchorIndex = InvalidIndex; + return true; +} + +bool UIKeyboardNavigationModel::MoveNext() { + if (m_itemCount == 0u) { + return false; + } + + if (m_currentIndex == InvalidIndex) { + return SetCurrentIndex(0u); + } + + if ((m_currentIndex + 1u) >= m_itemCount) { + return false; + } + + return SetCurrentIndex(m_currentIndex + 1u); +} + +bool UIKeyboardNavigationModel::MovePrevious() { + if (m_itemCount == 0u) { + return false; + } + + if (m_currentIndex == InvalidIndex) { + return SetCurrentIndex(m_itemCount - 1u); + } + + if (m_currentIndex == 0u) { + return false; + } + + return SetCurrentIndex(m_currentIndex - 1u); +} + +bool UIKeyboardNavigationModel::MoveHome() { + if (m_itemCount == 0u) { + return false; + } + + return SetCurrentIndex(0u); +} + +bool UIKeyboardNavigationModel::MoveEnd() { + if (m_itemCount == 0u) { + return false; + } + + return SetCurrentIndex(m_itemCount - 1u); +} + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Widgets/UIPropertyEditModel.cpp b/engine/src/UI/Widgets/UIPropertyEditModel.cpp new file mode 100644 index 00000000..4947be13 --- /dev/null +++ b/engine/src/UI/Widgets/UIPropertyEditModel.cpp @@ -0,0 +1,95 @@ +#include + +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +bool UIPropertyEditModel::HasActiveEdit() const { + return !m_activeFieldId.empty(); +} + +const std::string& UIPropertyEditModel::GetActiveFieldId() const { + return m_activeFieldId; +} + +const std::string& UIPropertyEditModel::GetStagedValue() const { + return m_stagedValue; +} + +bool UIPropertyEditModel::IsDirty() const { + return m_dirty; +} + +bool UIPropertyEditModel::BeginEdit(std::string fieldId, std::string initialValue) { + if (fieldId.empty()) { + return false; + } + + const bool stateChanged = + m_activeFieldId != fieldId || + m_baselineValue != initialValue || + m_stagedValue != initialValue || + m_dirty; + if (!stateChanged) { + return false; + } + + m_activeFieldId = std::move(fieldId); + m_baselineValue = std::move(initialValue); + m_stagedValue = m_baselineValue; + m_dirty = false; + return true; +} + +bool UIPropertyEditModel::UpdateStagedValue(std::string stagedValue) { + if (!HasActiveEdit()) { + return false; + } + + if (m_stagedValue == stagedValue) { + return false; + } + + m_stagedValue = std::move(stagedValue); + m_dirty = (m_stagedValue != m_baselineValue); + return true; +} + +bool UIPropertyEditModel::CommitEdit( + std::string* outFieldId, + std::string* outCommittedValue) { + if (!HasActiveEdit()) { + return false; + } + + if (outFieldId != nullptr) { + *outFieldId = m_activeFieldId; + } + if (outCommittedValue != nullptr) { + *outCommittedValue = m_stagedValue; + } + + m_activeFieldId.clear(); + m_baselineValue.clear(); + m_stagedValue.clear(); + m_dirty = false; + return true; +} + +bool UIPropertyEditModel::CancelEdit() { + if (!HasActiveEdit()) { + return false; + } + + m_activeFieldId.clear(); + m_baselineValue.clear(); + m_stagedValue.clear(); + m_dirty = false; + return true; +} + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp b/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp index 815f8a8b..83bae540 100644 --- a/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp +++ b/new_editor/src/XCUIBackend/XCUIDemoRuntime.cpp @@ -116,6 +116,7 @@ struct RuntimeBuildContext { bool accentEnabled = false; std::string resourceError = {}; std::string lastCommandId = {}; + std::vector pendingCommandIds = {}; XCUIAssetDocumentSource documentSource = XCUIAssetDocumentSource( XCUIAssetDocumentSource::MakeDemoPathSet()); fs::path repoRoot = {}; @@ -945,6 +946,15 @@ std::string ResolveTextInputStateKey(const DemoNode& node) { return !stateKey.empty() ? stateKey : node.elementKey; } +void RecordCommand(RuntimeBuildContext& state, std::string commandId) { + if (commandId.empty()) { + return; + } + + state.lastCommandId = commandId; + state.pendingCommandIds.push_back(std::move(commandId)); +} + void EnsureTextInputStateInitialized(RuntimeBuildContext& state, const DemoNode& node) { if (!IsTextInputNode(node)) { return; @@ -1061,7 +1071,7 @@ bool HandleTextInputCharacterInput( return false; } - state.lastCommandId = "demo.text.edit." + stateKey; + RecordCommand(state, "demo.text.edit." + stateKey); return true; } @@ -1089,9 +1099,9 @@ bool HandleTextInputKeyDown( } if (result.submitRequested) { - state.lastCommandId = "demo.text.submit." + stateKey; + RecordCommand(state, "demo.text.submit." + stateKey); } else if (result.valueChanged) { - state.lastCommandId = "demo.text.edit." + stateKey; + RecordCommand(state, "demo.text.edit." + stateKey); } return true; @@ -1130,7 +1140,7 @@ void ActivateNode(RuntimeBuildContext& state, UIElementId elementId) { state.accentEnabled = !state.accentEnabled; } - state.lastCommandId = BuildActivationCommandId(node); + RecordCommand(state, BuildActivationCommandId(node)); } void BuildDemoNodesRecursive( @@ -2149,6 +2159,13 @@ const XCUIDemoFrameResult& XCUIDemoRuntime::GetFrameResult() const { return m_state->data.frameResult; } +std::vector XCUIDemoRuntime::DrainPendingCommandIds() { + RuntimeBuildContext& state = m_state->data; + std::vector drainedCommandIds = std::move(state.pendingCommandIds); + state.pendingCommandIds.clear(); + return drainedCommandIds; +} + bool XCUIDemoRuntime::TryGetElementRect(const std::string& elementId, UI::UIRect& outRect) const { const RuntimeBuildContext& state = m_state->data; const auto it = state.nodeIndexByKey.find(elementId); diff --git a/new_editor/src/XCUIBackend/XCUIDemoRuntime.h b/new_editor/src/XCUIBackend/XCUIDemoRuntime.h index 2d923a6d..90a8dddd 100644 --- a/new_editor/src/XCUIBackend/XCUIDemoRuntime.h +++ b/new_editor/src/XCUIBackend/XCUIDemoRuntime.h @@ -65,6 +65,7 @@ public: const XCUIDemoFrameResult& Update(const XCUIDemoInputState& input); const XCUIDemoFrameResult& GetFrameResult() const; + std::vector DrainPendingCommandIds(); bool TryGetElementRect(const std::string& elementId, UI::UIRect& outRect) const; diff --git a/new_editor/src/XCUIBackend/XCUIEditorCommandRouter.cpp b/new_editor/src/XCUIBackend/XCUIEditorCommandRouter.cpp new file mode 100644 index 00000000..a75989cf --- /dev/null +++ b/new_editor/src/XCUIBackend/XCUIEditorCommandRouter.cpp @@ -0,0 +1,210 @@ +#include "XCUIBackend/XCUIEditorCommandRouter.h" + +#include + +namespace XCEngine { +namespace Editor { +namespace XCUIBackend { + +namespace { + +bool ModifiersMatch( + const UI::UIInputModifiers& required, + const UI::UIInputModifiers& actual, + bool exact) { + if (exact) { + return required.shift == actual.shift && + required.control == actual.control && + required.alt == actual.alt && + required.super == actual.super; + } + + return (!required.shift || actual.shift) && + (!required.control || actual.control) && + (!required.alt || actual.alt) && + (!required.super || actual.super); +} + +bool AcceleratorMatchesSnapshot( + const XCUIEditorCommandAccelerator& accelerator, + const XCUIEditorCommandInputSnapshot& snapshot) { + if (!accelerator.IsValid()) { + return false; + } + + const XCUIEditorCommandKeyState* keyState = snapshot.FindKeyState(accelerator.keyCode); + if (keyState == nullptr || !keyState->down) { + return false; + } + + if (keyState->repeat && !accelerator.allowRepeat) { + return false; + } + + return ModifiersMatch(accelerator.modifiers, snapshot.modifiers, accelerator.exactModifiers); +} + +} // namespace + +const XCUIEditorCommandKeyState* XCUIEditorCommandInputSnapshot::FindKeyState(std::int32_t keyCode) const { + for (const XCUIEditorCommandKeyState& keyState : keys) { + if (keyState.keyCode == keyCode) { + return &keyState; + } + } + + return nullptr; +} + +bool XCUIEditorCommandInputSnapshot::IsKeyDown(std::int32_t keyCode) const { + const XCUIEditorCommandKeyState* keyState = FindKeyState(keyCode); + return keyState != nullptr && keyState->down; +} + +bool XCUIEditorCommandAccelerator::IsValid() const { + return keyCode != 0; +} + +bool XCUIEditorCommandDefinition::IsValid() const { + return !commandId.empty() && static_cast(invoke); +} + +bool XCUIEditorCommandRouter::RegisterCommand(const XCUIEditorCommandDefinition& definition) { + if (!definition.IsValid()) { + return false; + } + + CommandRecord replacement = {}; + replacement.commandId = definition.commandId; + replacement.invoke = definition.invoke; + replacement.isEnabled = definition.isEnabled; + replacement.accelerators = definition.accelerators; + + if (CommandRecord* existing = FindRecord(definition.commandId)) { + *existing = std::move(replacement); + return true; + } + + m_commands.push_back(std::move(replacement)); + return true; +} + +bool XCUIEditorCommandRouter::UnregisterCommand(std::string_view commandId) { + const auto it = std::find_if( + m_commands.begin(), + m_commands.end(), + [commandId](const CommandRecord& record) { + return record.commandId == commandId; + }); + if (it == m_commands.end()) { + return false; + } + + m_commands.erase(it); + return true; +} + +void XCUIEditorCommandRouter::Clear() { + m_commands.clear(); +} + +bool XCUIEditorCommandRouter::HasCommand(std::string_view commandId) const { + return FindRecord(commandId) != nullptr; +} + +std::size_t XCUIEditorCommandRouter::GetCommandCount() const { + return m_commands.size(); +} + +bool XCUIEditorCommandRouter::IsCommandEnabled(std::string_view commandId) const { + const CommandRecord* record = FindRecord(commandId); + return record != nullptr && EvaluateEnabled(*record); +} + +bool XCUIEditorCommandRouter::InvokeCommand(std::string_view commandId) const { + const CommandRecord* record = FindRecord(commandId); + if (record == nullptr || !EvaluateEnabled(*record) || !record->invoke) { + return false; + } + + record->invoke(); + return true; +} + +XCUIEditorCommandShortcutMatch XCUIEditorCommandRouter::MatchShortcut( + const XCUIEditorCommandShortcutQuery& query) const { + XCUIEditorCommandShortcutMatch result = {}; + if (query.snapshot == nullptr) { + return result; + } + + const XCUIEditorCommandInputSnapshot& snapshot = *query.snapshot; + if (query.requireWindowFocused && !snapshot.windowFocused) { + return result; + } + if (!query.allowWhenKeyboardCaptured && snapshot.wantCaptureKeyboard) { + return result; + } + if (!query.allowWhenTextInputActive && snapshot.wantTextInput) { + return result; + } + + for (const CommandRecord& record : m_commands) { + if (!EvaluateEnabled(record)) { + continue; + } + + for (const XCUIEditorCommandAccelerator& accelerator : record.accelerators) { + if (!AcceleratorMatchesSnapshot(accelerator, snapshot)) { + continue; + } + + result.matched = true; + result.commandId = record.commandId; + result.accelerator = accelerator; + return result; + } + } + + return result; +} + +bool XCUIEditorCommandRouter::InvokeMatchingShortcut( + const XCUIEditorCommandShortcutQuery& query, + XCUIEditorCommandShortcutMatch* outMatch) const { + const XCUIEditorCommandShortcutMatch match = MatchShortcut(query); + if (outMatch != nullptr) { + *outMatch = match; + } + + return match.matched && InvokeCommand(match.commandId); +} + +XCUIEditorCommandRouter::CommandRecord* XCUIEditorCommandRouter::FindRecord(std::string_view commandId) { + const auto it = std::find_if( + m_commands.begin(), + m_commands.end(), + [commandId](const CommandRecord& record) { + return record.commandId == commandId; + }); + return it != m_commands.end() ? &(*it) : nullptr; +} + +const XCUIEditorCommandRouter::CommandRecord* XCUIEditorCommandRouter::FindRecord( + std::string_view commandId) const { + const auto it = std::find_if( + m_commands.begin(), + m_commands.end(), + [commandId](const CommandRecord& record) { + return record.commandId == commandId; + }); + return it != m_commands.end() ? &(*it) : nullptr; +} + +bool XCUIEditorCommandRouter::EvaluateEnabled(const CommandRecord& record) { + return !record.isEnabled || record.isEnabled(); +} + +} // namespace XCUIBackend +} // namespace Editor +} // namespace XCEngine diff --git a/new_editor/src/XCUIBackend/XCUIEditorCommandRouter.h b/new_editor/src/XCUIBackend/XCUIEditorCommandRouter.h new file mode 100644 index 00000000..b84c655e --- /dev/null +++ b/new_editor/src/XCUIBackend/XCUIEditorCommandRouter.h @@ -0,0 +1,101 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace XCEngine { +namespace Editor { +namespace XCUIBackend { + +struct XCUIEditorCommandKeyState { + std::int32_t keyCode = 0; + bool down = false; + bool repeat = false; +}; + +struct XCUIEditorCommandInputSnapshot { + UI::UIInputModifiers modifiers = {}; + bool windowFocused = false; + bool wantCaptureKeyboard = false; + bool wantTextInput = false; + std::vector keys = {}; + + const XCUIEditorCommandKeyState* FindKeyState(std::int32_t keyCode) const; + bool IsKeyDown(std::int32_t keyCode) const; +}; + +struct XCUIEditorCommandAccelerator { + std::int32_t keyCode = 0; + UI::UIInputModifiers modifiers = {}; + bool exactModifiers = true; + bool allowRepeat = false; + + bool IsValid() const; +}; + +struct XCUIEditorCommandDefinition { + using InvokeCallback = std::function; + using EnabledPredicate = std::function; + + std::string commandId = {}; + InvokeCallback invoke = {}; + EnabledPredicate isEnabled = {}; + std::vector accelerators = {}; + + bool IsValid() const; +}; + +struct XCUIEditorCommandShortcutQuery { + const XCUIEditorCommandInputSnapshot* snapshot = nullptr; + bool requireWindowFocused = true; + bool allowWhenKeyboardCaptured = false; + bool allowWhenTextInputActive = false; +}; + +struct XCUIEditorCommandShortcutMatch { + bool matched = false; + std::string commandId = {}; + XCUIEditorCommandAccelerator accelerator = {}; +}; + +class XCUIEditorCommandRouter { +public: + bool RegisterCommand(const XCUIEditorCommandDefinition& definition); + bool UnregisterCommand(std::string_view commandId); + void Clear(); + + bool HasCommand(std::string_view commandId) const; + std::size_t GetCommandCount() const; + bool IsCommandEnabled(std::string_view commandId) const; + + bool InvokeCommand(std::string_view commandId) const; + XCUIEditorCommandShortcutMatch MatchShortcut( + const XCUIEditorCommandShortcutQuery& query) const; + bool InvokeMatchingShortcut( + const XCUIEditorCommandShortcutQuery& query, + XCUIEditorCommandShortcutMatch* outMatch = nullptr) const; + +private: + struct CommandRecord { + std::string commandId = {}; + XCUIEditorCommandDefinition::InvokeCallback invoke = {}; + XCUIEditorCommandDefinition::EnabledPredicate isEnabled = {}; + std::vector accelerators = {}; + }; + + CommandRecord* FindRecord(std::string_view commandId); + const CommandRecord* FindRecord(std::string_view commandId) const; + static bool EvaluateEnabled(const CommandRecord& record); + + std::vector m_commands = {}; +}; + +} // namespace XCUIBackend +} // namespace Editor +} // namespace XCEngine diff --git a/tests/Core/UI/CMakeLists.txt b/tests/Core/UI/CMakeLists.txt index c90a6599..5b0e864b 100644 --- a/tests/Core/UI/CMakeLists.txt +++ b/tests/Core/UI/CMakeLists.txt @@ -6,6 +6,8 @@ set(UI_TEST_SOURCES test_ui_core.cpp test_ui_editor_collection_primitives.cpp test_ui_expansion_model.cpp + test_ui_keyboard_navigation_model.cpp + test_ui_property_edit_model.cpp test_layout_engine.cpp test_ui_selection_model.cpp test_ui_runtime.cpp diff --git a/tests/Core/UI/test_ui_keyboard_navigation_model.cpp b/tests/Core/UI/test_ui_keyboard_navigation_model.cpp new file mode 100644 index 00000000..41c81fd7 --- /dev/null +++ b/tests/Core/UI/test_ui_keyboard_navigation_model.cpp @@ -0,0 +1,101 @@ +#include + +#include + +namespace { + +using XCEngine::UI::Widgets::UIKeyboardNavigationModel; + +TEST(UIKeyboardNavigationModelTest, EmptyModelStartsWithoutCurrentIndexOrAnchor) { + UIKeyboardNavigationModel navigation = {}; + + EXPECT_EQ(navigation.GetItemCount(), 0u); + EXPECT_FALSE(navigation.HasCurrentIndex()); + EXPECT_EQ(navigation.GetCurrentIndex(), UIKeyboardNavigationModel::InvalidIndex); + EXPECT_FALSE(navigation.HasSelectionAnchor()); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), UIKeyboardNavigationModel::InvalidIndex); + EXPECT_FALSE(navigation.MoveNext()); + EXPECT_FALSE(navigation.MovePrevious()); + EXPECT_FALSE(navigation.MoveHome()); + EXPECT_FALSE(navigation.MoveEnd()); +} + +TEST(UIKeyboardNavigationModelTest, SetCurrentIndexAndDirectionalMovesTrackCurrentIndexAndAnchor) { + UIKeyboardNavigationModel navigation = {}; + ASSERT_TRUE(navigation.SetItemCount(4u)); + + EXPECT_TRUE(navigation.SetCurrentIndex(1u)); + EXPECT_EQ(navigation.GetCurrentIndex(), 1u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u); + + EXPECT_TRUE(navigation.MoveNext()); + EXPECT_EQ(navigation.GetCurrentIndex(), 2u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 2u); + + EXPECT_TRUE(navigation.MoveEnd()); + EXPECT_EQ(navigation.GetCurrentIndex(), 3u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u); + EXPECT_FALSE(navigation.MoveNext()); + + EXPECT_TRUE(navigation.MoveHome()); + EXPECT_EQ(navigation.GetCurrentIndex(), 0u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 0u); + EXPECT_FALSE(navigation.MovePrevious()); +} + +TEST(UIKeyboardNavigationModelTest, MovePreviousAndEndSeedNavigationWhenCurrentIndexIsUnset) { + UIKeyboardNavigationModel navigation = {}; + ASSERT_TRUE(navigation.SetItemCount(5u)); + + EXPECT_TRUE(navigation.MovePrevious()); + EXPECT_EQ(navigation.GetCurrentIndex(), 4u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 4u); + + EXPECT_TRUE(navigation.ClearCurrentIndex()); + EXPECT_TRUE(navigation.ClearSelectionAnchor()); + EXPECT_FALSE(navigation.HasCurrentIndex()); + EXPECT_FALSE(navigation.HasSelectionAnchor()); + + EXPECT_TRUE(navigation.MoveEnd()); + EXPECT_EQ(navigation.GetCurrentIndex(), 4u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 4u); +} + +TEST(UIKeyboardNavigationModelTest, ExplicitAnchorCanBePreservedUntilNavigationCollapsesIt) { + UIKeyboardNavigationModel navigation = {}; + ASSERT_TRUE(navigation.SetItemCount(6u)); + + EXPECT_TRUE(navigation.SetSelectionAnchorIndex(1u)); + EXPECT_TRUE(navigation.SetCurrentIndex(4u, false)); + EXPECT_EQ(navigation.GetCurrentIndex(), 4u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u); + + EXPECT_TRUE(navigation.MovePrevious()); + EXPECT_EQ(navigation.GetCurrentIndex(), 3u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u); +} + +TEST(UIKeyboardNavigationModelTest, ItemCountChangesClampCurrentIndexAndSelectionAnchor) { + UIKeyboardNavigationModel navigation = {}; + ASSERT_TRUE(navigation.SetItemCount(5u)); + ASSERT_TRUE(navigation.SetSelectionAnchorIndex(3u)); + ASSERT_TRUE(navigation.SetCurrentIndex(4u, false)); + + EXPECT_TRUE(navigation.SetItemCount(4u)); + EXPECT_EQ(navigation.GetCurrentIndex(), 3u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u); + + EXPECT_FALSE(navigation.SetCurrentIndex(3u, false)); + EXPECT_TRUE(navigation.SetSelectionAnchorIndex(2u)); + EXPECT_TRUE(navigation.SetItemCount(2u)); + EXPECT_EQ(navigation.GetCurrentIndex(), 1u); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u); + + EXPECT_TRUE(navigation.SetItemCount(0u)); + EXPECT_FALSE(navigation.HasCurrentIndex()); + EXPECT_EQ(navigation.GetCurrentIndex(), UIKeyboardNavigationModel::InvalidIndex); + EXPECT_FALSE(navigation.HasSelectionAnchor()); + EXPECT_EQ(navigation.GetSelectionAnchorIndex(), UIKeyboardNavigationModel::InvalidIndex); +} + +} // namespace diff --git a/tests/Core/UI/test_ui_property_edit_model.cpp b/tests/Core/UI/test_ui_property_edit_model.cpp new file mode 100644 index 00000000..4206469d --- /dev/null +++ b/tests/Core/UI/test_ui_property_edit_model.cpp @@ -0,0 +1,80 @@ +#include + +#include + +namespace { + +using XCEngine::UI::Widgets::UIPropertyEditModel; + +TEST(UIPropertyEditModelTest, BeginEditTracksActiveFieldAndInitialValue) { + UIPropertyEditModel model = {}; + + EXPECT_FALSE(model.HasActiveEdit()); + EXPECT_TRUE(model.GetActiveFieldId().empty()); + EXPECT_TRUE(model.GetStagedValue().empty()); + EXPECT_FALSE(model.IsDirty()); + + EXPECT_FALSE(model.BeginEdit("", "12.0")); + EXPECT_TRUE(model.BeginEdit("transform.position.x", "12.0")); + EXPECT_TRUE(model.HasActiveEdit()); + EXPECT_EQ(model.GetActiveFieldId(), "transform.position.x"); + EXPECT_EQ(model.GetStagedValue(), "12.0"); + EXPECT_FALSE(model.IsDirty()); + + EXPECT_FALSE(model.BeginEdit("transform.position.x", "12.0")); +} + +TEST(UIPropertyEditModelTest, UpdateStagedValueTracksDirtyAgainstBaseline) { + UIPropertyEditModel model = {}; + + EXPECT_FALSE(model.UpdateStagedValue("3.5")); + ASSERT_TRUE(model.BeginEdit("light.intensity", "1.0")); + + EXPECT_TRUE(model.UpdateStagedValue("3.5")); + EXPECT_EQ(model.GetStagedValue(), "3.5"); + EXPECT_TRUE(model.IsDirty()); + + EXPECT_FALSE(model.UpdateStagedValue("3.5")); + EXPECT_TRUE(model.UpdateStagedValue("1.0")); + EXPECT_EQ(model.GetStagedValue(), "1.0"); + EXPECT_FALSE(model.IsDirty()); +} + +TEST(UIPropertyEditModelTest, CommitEditReturnsPayloadAndClearsState) { + UIPropertyEditModel model = {}; + ASSERT_TRUE(model.BeginEdit("material.albedo", "#ffffff")); + ASSERT_TRUE(model.UpdateStagedValue("#ffcc00")); + + std::string committedFieldId = {}; + std::string committedValue = {}; + EXPECT_TRUE(model.CommitEdit(&committedFieldId, &committedValue)); + EXPECT_EQ(committedFieldId, "material.albedo"); + EXPECT_EQ(committedValue, "#ffcc00"); + + EXPECT_FALSE(model.HasActiveEdit()); + EXPECT_TRUE(model.GetActiveFieldId().empty()); + EXPECT_TRUE(model.GetStagedValue().empty()); + EXPECT_FALSE(model.IsDirty()); + EXPECT_FALSE(model.CommitEdit(&committedFieldId, &committedValue)); +} + +TEST(UIPropertyEditModelTest, CancelEditDropsStagedChangesAndResetsSession) { + UIPropertyEditModel model = {}; + ASSERT_TRUE(model.BeginEdit("camera.fov", "60")); + ASSERT_TRUE(model.UpdateStagedValue("75")); + ASSERT_TRUE(model.IsDirty()); + + EXPECT_TRUE(model.CancelEdit()); + EXPECT_FALSE(model.HasActiveEdit()); + EXPECT_TRUE(model.GetActiveFieldId().empty()); + EXPECT_TRUE(model.GetStagedValue().empty()); + EXPECT_FALSE(model.IsDirty()); + EXPECT_FALSE(model.CancelEdit()); + + EXPECT_TRUE(model.BeginEdit("camera.nearClip", "0.3")); + EXPECT_EQ(model.GetActiveFieldId(), "camera.nearClip"); + EXPECT_EQ(model.GetStagedValue(), "0.3"); + EXPECT_FALSE(model.IsDirty()); +} + +} // namespace diff --git a/tests/NewEditor/CMakeLists.txt b/tests/NewEditor/CMakeLists.txt index 82b18552..17e1147d 100644 --- a/tests/NewEditor/CMakeLists.txt +++ b/tests/NewEditor/CMakeLists.txt @@ -58,6 +58,12 @@ set(NEW_EDITOR_INPUT_BRIDGE_HEADER set(NEW_EDITOR_INPUT_BRIDGE_SOURCE ${CMAKE_SOURCE_DIR}/new_editor/src/XCUIBackend/XCUIInputBridge.cpp ) +set(NEW_EDITOR_COMMAND_ROUTER_HEADER + ${CMAKE_SOURCE_DIR}/new_editor/src/XCUIBackend/XCUIEditorCommandRouter.h +) +set(NEW_EDITOR_COMMAND_ROUTER_SOURCE + ${CMAKE_SOURCE_DIR}/new_editor/src/XCUIBackend/XCUIEditorCommandRouter.cpp +) set(NEW_EDITOR_IMGUI_INPUT_ADAPTER_HEADER ${CMAKE_SOURCE_DIR}/new_editor/src/XCUIBackend/ImGuiXCUIInputAdapter.h ) @@ -355,6 +361,33 @@ else() message(STATUS "Skipping new_editor_xcui_input_bridge_tests because input bridge files are missing.") endif() +if(EXISTS "${NEW_EDITOR_COMMAND_ROUTER_HEADER}" AND + EXISTS "${NEW_EDITOR_COMMAND_ROUTER_SOURCE}" AND + EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_xcui_editor_command_router.cpp") + add_executable(new_editor_xcui_editor_command_router_tests + test_xcui_editor_command_router.cpp + ${NEW_EDITOR_COMMAND_ROUTER_SOURCE} + ) + + xcengine_configure_new_editor_test_target(new_editor_xcui_editor_command_router_tests) + + target_link_libraries(new_editor_xcui_editor_command_router_tests + PRIVATE + XCEngine + GTest::gtest + GTest::gtest_main + ) + + target_include_directories(new_editor_xcui_editor_command_router_tests PRIVATE + ${CMAKE_SOURCE_DIR}/engine/include + ${CMAKE_SOURCE_DIR}/new_editor/src + ) + + xcengine_discover_new_editor_gtests(new_editor_xcui_editor_command_router_tests) +else() + message(STATUS "Skipping new_editor_xcui_editor_command_router_tests because command router files or the test source are missing.") +endif() + if(EXISTS "${NEW_EDITOR_IMGUI_INPUT_ADAPTER_HEADER}" AND EXISTS "${NEW_EDITOR_IMGUI_INPUT_ADAPTER_SOURCE}" AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/test_imgui_xcui_input_adapter.cpp" AND diff --git a/tests/NewEditor/test_imgui_window_ui_compositor.cpp b/tests/NewEditor/test_imgui_window_ui_compositor.cpp index 2124ef8f..6ee5acf3 100644 --- a/tests/NewEditor/test_imgui_window_ui_compositor.cpp +++ b/tests/NewEditor/test_imgui_window_ui_compositor.cpp @@ -189,6 +189,32 @@ TEST(ImGuiWindowUICompositorTest, RenderFrameCallsHostBeginUiAndPresentInOrder) (std::vector{ "begin", "ui", "present" })); } +TEST(ImGuiWindowUICompositorTest, RenderFrameWithoutUiCallbackStillBeginsAndPresents) { + auto host = std::make_unique(); + RecordingHostCompositor* hostPtr = host.get(); + + ImGuiWindowUICompositor compositor(std::move(host)); + D3D12WindowRenderer renderer = {}; + ASSERT_TRUE(compositor.Initialize(MakeFakeHwnd(), renderer, {})); + hostPtr->callOrder.clear(); + + constexpr float clearColor[4] = { 0.7f, 0.6f, 0.5f, 0.4f }; + compositor.RenderFrame(clearColor, {}, {}, {}); + + EXPECT_EQ(hostPtr->beginFrameCount, 1); + EXPECT_EQ(hostPtr->endFrameCount, 1); + EXPECT_EQ(hostPtr->presentedRenderer, &renderer); + EXPECT_FALSE(hostPtr->beforeUiRenderProvided); + EXPECT_FALSE(hostPtr->afterUiRenderProvided); + EXPECT_EQ(hostPtr->lastClearColor[0], clearColor[0]); + EXPECT_EQ(hostPtr->lastClearColor[1], clearColor[1]); + EXPECT_EQ(hostPtr->lastClearColor[2], clearColor[2]); + EXPECT_EQ(hostPtr->lastClearColor[3], clearColor[3]); + EXPECT_EQ( + hostPtr->callOrder, + (std::vector{ "begin", "present" })); +} + TEST(ImGuiWindowUICompositorTest, HandleWindowMessageAndTextureRegistrationForwardToHost) { auto host = std::make_unique(); RecordingHostCompositor* hostPtr = host.get(); @@ -222,6 +248,30 @@ TEST(ImGuiWindowUICompositorTest, HandleWindowMessageAndTextureRegistrationForwa EXPECT_EQ(hostPtr->freedRegistration.texture.nativeHandle, registration.texture.nativeHandle); } +TEST(ImGuiWindowUICompositorTest, SecondInitializeRebindsRendererForSubsequentFrames) { + auto host = std::make_unique(); + RecordingHostCompositor* hostPtr = host.get(); + + ImGuiWindowUICompositor compositor(std::move(host)); + D3D12WindowRenderer firstRenderer = {}; + D3D12WindowRenderer secondRenderer = {}; + + ASSERT_TRUE(compositor.Initialize(MakeFakeHwnd(), firstRenderer, {})); + ASSERT_TRUE(compositor.Initialize(MakeFakeHwnd(), secondRenderer, {})); + EXPECT_EQ(hostPtr->initializeCount, 2); + EXPECT_EQ(hostPtr->initializedRenderer, &secondRenderer); + + compositor.RenderFrame( + std::array{ 0.2f, 0.3f, 0.4f, 1.0f }.data(), + []() {}, + {}, + {}); + + EXPECT_EQ(hostPtr->beginFrameCount, 1); + EXPECT_EQ(hostPtr->endFrameCount, 1); + EXPECT_EQ(hostPtr->presentedRenderer, &secondRenderer); +} + TEST(ImGuiWindowUICompositorTest, ShutdownClearsRendererBindingAndPreventsFurtherRender) { auto host = std::make_unique(); RecordingHostCompositor* hostPtr = host.get(); diff --git a/tests/NewEditor/test_xcui_demo_runtime.cpp b/tests/NewEditor/test_xcui_demo_runtime.cpp index 067a1e11..2d763447 100644 --- a/tests/NewEditor/test_xcui_demo_runtime.cpp +++ b/tests/NewEditor/test_xcui_demo_runtime.cpp @@ -219,6 +219,100 @@ TEST(NewEditorXCUIDemoRuntimeTest, InputStateTransitionsAreAcceptedAndFrameStill } } +TEST(NewEditorXCUIDemoRuntimeTest, DrainPendingCommandIdsReturnsEmptyForFramesWithoutCommands) { + XCEngine::Editor::XCUIBackend::XCUIDemoRuntime runtime; + ASSERT_TRUE(runtime.ReloadDocuments()); + + const auto& frame = runtime.Update(BuildInputState()); + ASSERT_TRUE(frame.stats.documentsReady); + + const std::vector drainedCommandIds = runtime.DrainPendingCommandIds(); + EXPECT_TRUE(drainedCommandIds.empty()); + EXPECT_TRUE(runtime.DrainPendingCommandIds().empty()); +} + +TEST(NewEditorXCUIDemoRuntimeTest, DrainPendingCommandIdsCapturesPointerActivationCommands) { + XCEngine::Editor::XCUIBackend::XCUIDemoRuntime runtime; + ASSERT_TRUE(runtime.ReloadDocuments()); + + const auto& baselineFrame = runtime.Update(BuildInputState()); + ASSERT_TRUE(baselineFrame.stats.documentsReady); + EXPECT_TRUE(runtime.DrainPendingCommandIds().empty()); + + XCEngine::UI::UIRect buttonRect = {}; + ASSERT_TRUE(runtime.TryGetElementRect("toggleAccent", buttonRect)); + + const XCEngine::UI::UIPoint buttonCenter( + buttonRect.x + buttonRect.width * 0.5f, + buttonRect.y + buttonRect.height * 0.5f); + + XCEngine::Editor::XCUIBackend::XCUIDemoInputState pressedInput = BuildInputState(); + pressedInput.pointerPosition = buttonCenter; + pressedInput.pointerPressed = true; + pressedInput.pointerDown = true; + runtime.Update(pressedInput); + EXPECT_TRUE(runtime.DrainPendingCommandIds().empty()); + + XCEngine::Editor::XCUIBackend::XCUIDemoInputState releasedInput = BuildInputState(); + releasedInput.pointerPosition = buttonCenter; + releasedInput.pointerReleased = true; + const auto& toggledFrame = runtime.Update(releasedInput); + + ASSERT_TRUE(toggledFrame.stats.documentsReady); + EXPECT_EQ(runtime.DrainPendingCommandIds(), std::vector({ "demo.toggleAccent" })); + EXPECT_TRUE(runtime.DrainPendingCommandIds().empty()); +} + +TEST(NewEditorXCUIDemoRuntimeTest, DrainPendingCommandIdsPreservesMultipleTextEditCommandsPerFrame) { + XCEngine::Editor::XCUIBackend::XCUIDemoRuntime runtime; + ASSERT_TRUE(runtime.ReloadDocuments()); + + const auto& baselineFrame = runtime.Update(BuildInputState()); + ASSERT_TRUE(baselineFrame.stats.documentsReady); + EXPECT_TRUE(runtime.DrainPendingCommandIds().empty()); + + XCEngine::UI::UIRect promptRect = {}; + ASSERT_TRUE(runtime.TryGetElementRect("agentPrompt", promptRect)); + + const XCEngine::UI::UIPoint promptCenter( + promptRect.x + promptRect.width * 0.5f, + promptRect.y + promptRect.height * 0.5f); + + XCEngine::Editor::XCUIBackend::XCUIDemoInputState pressedInput = BuildInputState(); + pressedInput.pointerPosition = promptCenter; + pressedInput.pointerPressed = true; + pressedInput.pointerDown = true; + runtime.Update(pressedInput); + EXPECT_TRUE(runtime.DrainPendingCommandIds().empty()); + + XCEngine::Editor::XCUIBackend::XCUIDemoInputState releasedInput = BuildInputState(); + releasedInput.pointerPosition = promptCenter; + releasedInput.pointerReleased = true; + const auto& focusedFrame = runtime.Update(releasedInput); + + ASSERT_TRUE(focusedFrame.stats.documentsReady); + EXPECT_EQ(focusedFrame.stats.focusedElementId, "agentPrompt"); + EXPECT_EQ( + runtime.DrainPendingCommandIds(), + std::vector({ "demo.activate.agentPrompt" })); + + XCEngine::Editor::XCUIBackend::XCUIDemoInputState textInput = BuildInputState(); + textInput.events.push_back(MakeCharacterEvent('A')); + textInput.events.push_back(MakeCharacterEvent('I')); + textInput.events.push_back(MakeKeyDownEvent(XCEngine::Input::KeyCode::Backspace)); + const auto& typedFrame = runtime.Update(textInput); + + ASSERT_TRUE(typedFrame.stats.documentsReady); + EXPECT_EQ(typedFrame.stats.focusedElementId, "agentPrompt"); + EXPECT_EQ( + runtime.DrainPendingCommandIds(), + std::vector({ + "demo.text.edit.agentPrompt", + "demo.text.edit.agentPrompt", + "demo.text.edit.agentPrompt" })); + EXPECT_TRUE(runtime.DrainPendingCommandIds().empty()); +} + TEST(NewEditorXCUIDemoRuntimeTest, PointerToggleUpdatesFocusStatusTextAndAccentState) { XCEngine::Editor::XCUIBackend::XCUIDemoRuntime runtime; ASSERT_TRUE(runtime.ReloadDocuments()); diff --git a/tests/NewEditor/test_xcui_editor_command_router.cpp b/tests/NewEditor/test_xcui_editor_command_router.cpp new file mode 100644 index 00000000..3d890c35 --- /dev/null +++ b/tests/NewEditor/test_xcui_editor_command_router.cpp @@ -0,0 +1,205 @@ +#include "XCUIBackend/XCUIEditorCommandRouter.h" + +#include + +#include + +namespace { + +using XCEngine::Editor::XCUIBackend::XCUIEditorCommandAccelerator; +using XCEngine::Editor::XCUIBackend::XCUIEditorCommandDefinition; +using XCEngine::Editor::XCUIBackend::XCUIEditorCommandInputSnapshot; +using XCEngine::Editor::XCUIBackend::XCUIEditorCommandKeyState; +using XCEngine::Editor::XCUIBackend::XCUIEditorCommandRouter; +using XCEngine::Editor::XCUIBackend::XCUIEditorCommandShortcutMatch; +using XCEngine::Editor::XCUIBackend::XCUIEditorCommandShortcutQuery; +using XCEngine::Input::KeyCode; + +XCUIEditorCommandKeyState MakeKeyState(std::int32_t keyCode, bool down, bool repeat = false) { + XCUIEditorCommandKeyState state = {}; + state.keyCode = keyCode; + state.down = down; + state.repeat = repeat; + return state; +} + +XCUIEditorCommandInputSnapshot MakeSnapshot( + std::initializer_list keys, + const XCEngine::UI::UIInputModifiers& modifiers = {}, + bool windowFocused = true) { + XCUIEditorCommandInputSnapshot snapshot = {}; + snapshot.keys.assign(keys.begin(), keys.end()); + snapshot.modifiers = modifiers; + snapshot.windowFocused = windowFocused; + return snapshot; +} + +TEST(XCUIEditorCommandRouterTest, RegisterInvokeAndUnregisterTrackCommandsById) { + XCUIEditorCommandRouter router = {}; + int invokeCount = 0; + + XCUIEditorCommandDefinition definition = {}; + definition.commandId = "xcui.file.save"; + definition.invoke = [&invokeCount]() { ++invokeCount; }; + + EXPECT_TRUE(router.RegisterCommand(definition)); + EXPECT_EQ(router.GetCommandCount(), 1u); + EXPECT_TRUE(router.HasCommand("xcui.file.save")); + EXPECT_TRUE(router.IsCommandEnabled("xcui.file.save")); + EXPECT_TRUE(router.InvokeCommand("xcui.file.save")); + EXPECT_EQ(invokeCount, 1); + + EXPECT_TRUE(router.UnregisterCommand("xcui.file.save")); + EXPECT_FALSE(router.HasCommand("xcui.file.save")); + EXPECT_FALSE(router.InvokeCommand("xcui.file.save")); + EXPECT_EQ(router.GetCommandCount(), 0u); +} + +TEST(XCUIEditorCommandRouterTest, RegisterRejectsMissingIdOrHandlerAndDuplicateIdReplacesEntry) { + XCUIEditorCommandRouter router = {}; + int invokeCount = 0; + + XCUIEditorCommandDefinition invalid = {}; + invalid.commandId = "xcui.invalid"; + EXPECT_FALSE(router.RegisterCommand(invalid)); + + invalid = {}; + invalid.invoke = []() {}; + EXPECT_FALSE(router.RegisterCommand(invalid)); + + XCUIEditorCommandDefinition first = {}; + first.commandId = "xcui.edit.rename"; + first.invoke = [&invokeCount]() { invokeCount += 1; }; + first.accelerators.push_back({ static_cast(KeyCode::F2), {}, true, false }); + ASSERT_TRUE(router.RegisterCommand(first)); + + XCUIEditorCommandDefinition replacement = {}; + replacement.commandId = "xcui.edit.rename"; + replacement.invoke = [&invokeCount]() { invokeCount += 10; }; + replacement.accelerators.push_back({ static_cast(KeyCode::Enter), {}, true, false }); + ASSERT_TRUE(router.RegisterCommand(replacement)); + + EXPECT_EQ(router.GetCommandCount(), 1u); + EXPECT_TRUE(router.InvokeCommand("xcui.edit.rename")); + EXPECT_EQ(invokeCount, 10); + + const XCUIEditorCommandInputSnapshot f2Snapshot = + MakeSnapshot({ MakeKeyState(static_cast(KeyCode::F2), true) }); + const auto f2Match = router.MatchShortcut({ &f2Snapshot }); + EXPECT_FALSE(f2Match.matched); + + const XCUIEditorCommandInputSnapshot enterSnapshot = + MakeSnapshot({ MakeKeyState(static_cast(KeyCode::Enter), true) }); + const auto enterMatch = router.MatchShortcut({ &enterSnapshot }); + EXPECT_TRUE(enterMatch.matched); + EXPECT_EQ(enterMatch.commandId, "xcui.edit.rename"); +} + +TEST(XCUIEditorCommandRouterTest, DisabledPredicateBlocksDirectAndShortcutInvocation) { + XCUIEditorCommandRouter router = {}; + bool enabled = false; + int invokeCount = 0; + + XCUIEditorCommandDefinition definition = {}; + definition.commandId = "xcui.edit.delete"; + definition.invoke = [&invokeCount]() { ++invokeCount; }; + definition.isEnabled = [&enabled]() { return enabled; }; + definition.accelerators.push_back({ static_cast(KeyCode::Delete), {}, true, false }); + ASSERT_TRUE(router.RegisterCommand(definition)); + + const XCUIEditorCommandInputSnapshot snapshot = + MakeSnapshot({ MakeKeyState(static_cast(KeyCode::Delete), true) }); + EXPECT_FALSE(router.IsCommandEnabled("xcui.edit.delete")); + EXPECT_FALSE(router.InvokeCommand("xcui.edit.delete")); + EXPECT_FALSE(router.MatchShortcut({ &snapshot }).matched); + EXPECT_FALSE(router.InvokeMatchingShortcut({ &snapshot })); + EXPECT_EQ(invokeCount, 0); + + enabled = true; + EXPECT_TRUE(router.IsCommandEnabled("xcui.edit.delete")); + EXPECT_TRUE(router.InvokeMatchingShortcut({ &snapshot })); + EXPECT_EQ(invokeCount, 1); +} + +TEST(XCUIEditorCommandRouterTest, ShortcutMatchingRespectsModifiersRepeatAndPolicyFlags) { + XCUIEditorCommandRouter router = {}; + int invokeCount = 0; + + XCEngine::UI::UIInputModifiers modifiers = {}; + modifiers.control = true; + modifiers.shift = true; + + XCUIEditorCommandDefinition definition = {}; + definition.commandId = "xcui.search.command_palette"; + definition.invoke = [&invokeCount]() { ++invokeCount; }; + definition.accelerators.push_back({ + static_cast(KeyCode::P), + modifiers, + true, + false }); + ASSERT_TRUE(router.RegisterCommand(definition)); + + const XCUIEditorCommandInputSnapshot exactSnapshot = MakeSnapshot( + { MakeKeyState(static_cast(KeyCode::P), true) }, + modifiers); + auto match = router.MatchShortcut({ &exactSnapshot }); + ASSERT_TRUE(match.matched); + EXPECT_EQ(match.commandId, "xcui.search.command_palette"); + + const XCUIEditorCommandInputSnapshot repeatedSnapshot = MakeSnapshot( + { MakeKeyState(static_cast(KeyCode::P), true, true) }, + modifiers); + EXPECT_FALSE(router.MatchShortcut({ &repeatedSnapshot }).matched); + + XCUIEditorCommandInputSnapshot captureSnapshot = exactSnapshot; + captureSnapshot.wantCaptureKeyboard = true; + EXPECT_FALSE(router.MatchShortcut({ &captureSnapshot }).matched); + EXPECT_TRUE(router.MatchShortcut({ &captureSnapshot, true, true, false }).matched); + + XCUIEditorCommandInputSnapshot textInputSnapshot = exactSnapshot; + textInputSnapshot.wantTextInput = true; + EXPECT_FALSE(router.MatchShortcut({ &textInputSnapshot }).matched); + EXPECT_TRUE(router.MatchShortcut({ &textInputSnapshot, true, false, true }).matched); + + XCUIEditorCommandInputSnapshot unfocusedSnapshot = exactSnapshot; + unfocusedSnapshot.windowFocused = false; + EXPECT_FALSE(router.MatchShortcut({ &unfocusedSnapshot }).matched); + EXPECT_TRUE(router.MatchShortcut({ &unfocusedSnapshot, false, false, false }).matched); + + XCUIEditorCommandShortcutMatch invokedMatch = {}; + EXPECT_TRUE(router.InvokeMatchingShortcut({ &exactSnapshot }, &invokedMatch)); + EXPECT_TRUE(invokedMatch.matched); + EXPECT_EQ(invokedMatch.commandId, "xcui.search.command_palette"); + EXPECT_EQ(invokeCount, 1); +} + +TEST(XCUIEditorCommandRouterTest, NonExactModifierAcceleratorsAllowAdditionalPressedModifiers) { + XCUIEditorCommandRouter router = {}; + + XCEngine::UI::UIInputModifiers requiredModifiers = {}; + requiredModifiers.control = true; + + XCUIEditorCommandDefinition definition = {}; + definition.commandId = "xcui.edit.duplicate"; + definition.invoke = []() {}; + definition.accelerators.push_back({ + static_cast(KeyCode::D), + requiredModifiers, + false, + false }); + ASSERT_TRUE(router.RegisterCommand(definition)); + + XCEngine::UI::UIInputModifiers actualModifiers = requiredModifiers; + actualModifiers.shift = true; + const XCUIEditorCommandInputSnapshot permissiveSnapshot = MakeSnapshot( + { MakeKeyState(static_cast(KeyCode::D), true) }, + actualModifiers); + EXPECT_TRUE(router.MatchShortcut({ &permissiveSnapshot }).matched); + + XCUIEditorCommandRouter exactRouter = {}; + definition.accelerators.front().exactModifiers = true; + ASSERT_TRUE(exactRouter.RegisterCommand(definition)); + EXPECT_FALSE(exactRouter.MatchShortcut({ &permissiveSnapshot }).matched); +} + +} // namespace diff --git a/tests/NewEditor/test_xcui_hosted_preview_presenter.cpp b/tests/NewEditor/test_xcui_hosted_preview_presenter.cpp index edc79e7b..e8541afb 100644 --- a/tests/NewEditor/test_xcui_hosted_preview_presenter.cpp +++ b/tests/NewEditor/test_xcui_hosted_preview_presenter.cpp @@ -242,6 +242,34 @@ TEST(XCUIHostedPreviewPresenterTest, QueuedNativePresenterFallsBackLogicalSizeTo EXPECT_FLOAT_EQ(image.uvMax.y, 0.5f); } +TEST(XCUIHostedPreviewPresenterTest, QueuedNativePresenterFallsBackWhenLogicalSizeIsPartiallySpecified) { + XCUIHostedPreviewQueue queue = {}; + XCUIHostedPreviewSurfaceRegistry surfaceRegistry = {}; + queue.BeginFrame(); + + std::unique_ptr presenter = + CreateQueuedNativeXCUIHostedPreviewPresenter(queue, surfaceRegistry); + ASSERT_NE(presenter, nullptr); + + XCEngine::UI::UIDrawData drawData = {}; + drawData.EmplaceDrawList("HostedPreviewPartialLogicalSize").AddFilledRect( + XCEngine::UI::UIRect(2.0f, 3.0f, 18.0f, 9.0f), + XCEngine::UI::UIColor(0.1f, 0.2f, 0.3f, 1.0f)); + + XCUIHostedPreviewFrame frame = {}; + frame.drawData = &drawData; + frame.debugName = "XCUI Partial Logical Size"; + frame.canvasRect = XCEngine::UI::UIRect(10.0f, 20.0f, 300.0f, 140.0f); + frame.logicalSize = XCEngine::UI::UISize(512.0f, 0.0f); + + ASSERT_TRUE(presenter->Present(frame)); + ASSERT_EQ(queue.GetQueuedFrames().size(), 1u); + + const XCUIHostedPreviewQueuedFrame& queuedFrame = queue.GetQueuedFrames().front(); + EXPECT_FLOAT_EQ(queuedFrame.logicalSize.width, 300.0f); + EXPECT_FLOAT_EQ(queuedFrame.logicalSize.height, 140.0f); +} + TEST(XCUIHostedPreviewPresenterTest, QueuedNativePresenterRejectsMissingDrawDataAndLeavesQueueUntouched) { XCUIHostedPreviewQueue queue = {}; XCUIHostedPreviewSurfaceRegistry surfaceRegistry = {}; @@ -430,6 +458,61 @@ TEST(XCUIHostedPreviewPresenterTest, SurfaceRegistryTracksQueuedFrameMetadataAlo EXPECT_TRUE(descriptor.image.IsValid()); } +TEST(XCUIHostedPreviewPresenterTest, SurfaceRegistryReusesDescriptorForRepeatedQueuedFrameKeys) { + XCUIHostedPreviewSurfaceRegistry surfaceRegistry = {}; + surfaceRegistry.BeginFrame(); + + surfaceRegistry.UpdateSurface( + "XCUI Demo", + MakeHostedPreviewTextureHandle(31u, 400u, 200u), + XCEngine::UI::UIRect(0.0f, 0.0f, 200.0f, 100.0f)); + + XCUIHostedPreviewQueuedFrame firstQueuedFrame = {}; + firstQueuedFrame.debugName = "XCUI Demo"; + firstQueuedFrame.debugSource = "tests.hosted_preview.first"; + firstQueuedFrame.canvasRect = XCEngine::UI::UIRect(8.0f, 12.0f, 320.0f, 180.0f); + firstQueuedFrame.logicalSize = XCEngine::UI::UISize(640.0f, 360.0f); + firstQueuedFrame.drawData.EmplaceDrawList("First").AddFilledRect( + XCEngine::UI::UIRect(0.0f, 0.0f, 16.0f, 16.0f), + XCEngine::UI::UIColor(1.0f, 0.0f, 0.0f, 1.0f)); + + XCUIHostedPreviewQueuedFrame secondQueuedFrame = {}; + secondQueuedFrame.debugName = "XCUI Demo"; + secondQueuedFrame.debugSource = "tests.hosted_preview.second"; + secondQueuedFrame.canvasRect = XCEngine::UI::UIRect(20.0f, 24.0f, 256.0f, 144.0f); + secondQueuedFrame.logicalSize = XCEngine::UI::UISize(512.0f, 288.0f); + XCEngine::UI::UIDrawList& secondDrawList = secondQueuedFrame.drawData.EmplaceDrawList("Second"); + secondDrawList.AddFilledRect( + XCEngine::UI::UIRect(1.0f, 2.0f, 20.0f, 10.0f), + XCEngine::UI::UIColor(0.0f, 1.0f, 0.0f, 1.0f)); + secondDrawList.AddText( + XCEngine::UI::UIPoint(4.0f, 6.0f), + "second", + XCEngine::UI::UIColor(1.0f, 1.0f, 1.0f, 1.0f), + 12.0f); + + surfaceRegistry.RecordQueuedFrame(firstQueuedFrame, 1u); + surfaceRegistry.RecordQueuedFrame(secondQueuedFrame, 7u); + + ASSERT_EQ(surfaceRegistry.GetDescriptors().size(), 1u); + + XCUIHostedPreviewSurfaceDescriptor descriptor = {}; + ASSERT_TRUE(surfaceRegistry.TryGetSurfaceDescriptor("XCUI Demo", descriptor)); + EXPECT_EQ(descriptor.debugSource, "tests.hosted_preview.second"); + EXPECT_FLOAT_EQ(descriptor.canvasRect.x, 20.0f); + EXPECT_FLOAT_EQ(descriptor.canvasRect.y, 24.0f); + EXPECT_FLOAT_EQ(descriptor.canvasRect.width, 256.0f); + EXPECT_FLOAT_EQ(descriptor.canvasRect.height, 144.0f); + EXPECT_FLOAT_EQ(descriptor.logicalSize.width, 512.0f); + EXPECT_FLOAT_EQ(descriptor.logicalSize.height, 288.0f); + EXPECT_EQ(descriptor.queuedFrameIndex, 7u); + EXPECT_EQ(descriptor.submittedDrawListCount, 1u); + EXPECT_EQ(descriptor.submittedCommandCount, 2u); + EXPECT_TRUE(descriptor.queuedThisFrame); + EXPECT_TRUE(descriptor.image.IsValid()); + EXPECT_EQ(descriptor.image.texture.nativeHandle, 31u); +} + TEST(XCUIHostedPreviewPresenterTest, SurfaceRegistryRejectsInvalidSurfaceUpdatesWithoutClobberingExistingImage) { XCUIHostedPreviewSurfaceRegistry surfaceRegistry = {}; XCUIHostedPreviewSurfaceDescriptor descriptor = {};