Add XCUI command routing and widget state models

This commit is contained in:
2026-04-05 12:10:55 +08:00
parent 511e94fd30
commit 68c4c80b06
18 changed files with 1329 additions and 9 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,40 @@
#pragma once
#include <cstddef>
namespace XCEngine {
namespace UI {
namespace Widgets {
class UIKeyboardNavigationModel {
public:
static constexpr std::size_t InvalidIndex = static_cast<std::size_t>(-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

View File

@@ -0,0 +1,32 @@
#pragma once
#include <string>
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

View File

@@ -0,0 +1,162 @@
#include <XCEngine/UI/Widgets/UIKeyboardNavigationModel.h>
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

View File

@@ -0,0 +1,95 @@
#include <XCEngine/UI/Widgets/UIPropertyEditModel.h>
#include <utility>
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

View File

@@ -116,6 +116,7 @@ struct RuntimeBuildContext {
bool accentEnabled = false;
std::string resourceError = {};
std::string lastCommandId = {};
std::vector<std::string> 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<std::string> XCUIDemoRuntime::DrainPendingCommandIds() {
RuntimeBuildContext& state = m_state->data;
std::vector<std::string> 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);

View File

@@ -65,6 +65,7 @@ public:
const XCUIDemoFrameResult& Update(const XCUIDemoInputState& input);
const XCUIDemoFrameResult& GetFrameResult() const;
std::vector<std::string> DrainPendingCommandIds();
bool TryGetElementRect(const std::string& elementId, UI::UIRect& outRect) const;

View File

@@ -0,0 +1,210 @@
#include "XCUIBackend/XCUIEditorCommandRouter.h"
#include <algorithm>
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<bool>(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

View File

@@ -0,0 +1,101 @@
#pragma once
#include <XCEngine/UI/Types.h>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <string_view>
#include <vector>
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<XCUIEditorCommandKeyState> 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<void()>;
using EnabledPredicate = std::function<bool()>;
std::string commandId = {};
InvokeCallback invoke = {};
EnabledPredicate isEnabled = {};
std::vector<XCUIEditorCommandAccelerator> 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<XCUIEditorCommandAccelerator> accelerators = {};
};
CommandRecord* FindRecord(std::string_view commandId);
const CommandRecord* FindRecord(std::string_view commandId) const;
static bool EvaluateEnabled(const CommandRecord& record);
std::vector<CommandRecord> m_commands = {};
};
} // namespace XCUIBackend
} // namespace Editor
} // namespace XCEngine

View File

@@ -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

View File

@@ -0,0 +1,101 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIKeyboardNavigationModel.h>
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

View File

@@ -0,0 +1,80 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIPropertyEditModel.h>
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

View File

@@ -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

View File

@@ -189,6 +189,32 @@ TEST(ImGuiWindowUICompositorTest, RenderFrameCallsHostBeginUiAndPresentInOrder)
(std::vector<std::string>{ "begin", "ui", "present" }));
}
TEST(ImGuiWindowUICompositorTest, RenderFrameWithoutUiCallbackStillBeginsAndPresents) {
auto host = std::make_unique<RecordingHostCompositor>();
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<std::string>{ "begin", "present" }));
}
TEST(ImGuiWindowUICompositorTest, HandleWindowMessageAndTextureRegistrationForwardToHost) {
auto host = std::make_unique<RecordingHostCompositor>();
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>();
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<float, 4>{ 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>();
RecordingHostCompositor* hostPtr = host.get();

View File

@@ -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<std::string> 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<std::string>({ "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<std::string>({ "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<std::string>({
"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());

View File

@@ -0,0 +1,205 @@
#include "XCUIBackend/XCUIEditorCommandRouter.h"
#include <XCEngine/Input/InputTypes.h>
#include <gtest/gtest.h>
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<XCUIEditorCommandKeyState> 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<std::int32_t>(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<std::int32_t>(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<std::int32_t>(KeyCode::F2), true) });
const auto f2Match = router.MatchShortcut({ &f2Snapshot });
EXPECT_FALSE(f2Match.matched);
const XCUIEditorCommandInputSnapshot enterSnapshot =
MakeSnapshot({ MakeKeyState(static_cast<std::int32_t>(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<std::int32_t>(KeyCode::Delete), {}, true, false });
ASSERT_TRUE(router.RegisterCommand(definition));
const XCUIEditorCommandInputSnapshot snapshot =
MakeSnapshot({ MakeKeyState(static_cast<std::int32_t>(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<std::int32_t>(KeyCode::P),
modifiers,
true,
false });
ASSERT_TRUE(router.RegisterCommand(definition));
const XCUIEditorCommandInputSnapshot exactSnapshot = MakeSnapshot(
{ MakeKeyState(static_cast<std::int32_t>(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<std::int32_t>(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<std::int32_t>(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<std::int32_t>(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

View File

@@ -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<IXCUIHostedPreviewPresenter> 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 = {};