Build XCUI splitter foundation and test harness

This commit is contained in:
2026-04-06 03:17:53 +08:00
parent dc17685099
commit c7dc8d7484
77 changed files with 4749 additions and 542 deletions

View File

@@ -16,7 +16,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
enable_testing() enable_testing()
option(XCENGINE_ENABLE_MONO_SCRIPTING "Build the Mono-based C# scripting runtime" ON) option(XCENGINE_ENABLE_MONO_SCRIPTING "Build the Mono-based C# scripting runtime" ON)
option(XCENGINE_BUILD_NEW_EDITOR "Build the experimental new_editor skeleton" ON) option(XCENGINE_BUILD_NEW_EDITOR "Build the XCUI new_editor shell app" ON)
set( set(
XCENGINE_MONO_ROOT_DIR XCENGINE_MONO_ROOT_DIR
"${CMAKE_SOURCE_DIR}/参考/Fermion/Fermion/external/mono" "${CMAKE_SOURCE_DIR}/参考/Fermion/Fermion/external/mono"
@@ -25,9 +25,7 @@ set(
add_subdirectory(engine) add_subdirectory(engine)
add_subdirectory(editor) add_subdirectory(editor)
if(XCENGINE_BUILD_NEW_EDITOR) add_subdirectory(new_editor)
add_subdirectory(new_editor)
endif()
add_subdirectory(managed) add_subdirectory(managed)
add_subdirectory(mvs/RenderDoc) add_subdirectory(mvs/RenderDoc)
add_subdirectory(tests) add_subdirectory(tests)

View File

@@ -533,12 +533,10 @@ add_library(XCEngine STATIC
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Text/UITextInputController.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Text/UITextInputController.h
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextEditing.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextEditing.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextInputController.cpp ${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/UIExpansionModel.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIKeyboardNavigationModel.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/UIPropertyEditModel.h
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UISelectionModel.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/UIExpansionModel.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIKeyboardNavigationModel.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/UIPropertyEditModel.cpp

View File

@@ -19,7 +19,11 @@ struct UIInputDispatcherOptions {
struct UIInputDispatchSummary { struct UIInputDispatchSummary {
UIFocusChange focusChange = {}; UIFocusChange focusChange = {};
UIInputDispatchResult routing = {}; UIInputDispatchResult routing = {};
bool shortcutMatched = false;
bool shortcutHandled = false; bool shortcutHandled = false;
bool shortcutSuppressed = false;
UIShortcutScope shortcutScope = UIShortcutScope::Global;
UIElementId shortcutOwnerId = 0;
std::string commandId = {}; std::string commandId = {};
bool Handled() const { bool Handled() const {
@@ -50,6 +54,14 @@ public:
return m_shortcutRegistry; return m_shortcutRegistry;
} }
void SetShortcutContext(const UIShortcutContext& context) {
m_shortcutContext = context;
}
const UIShortcutContext& GetShortcutContext() const {
return m_shortcutContext;
}
template <typename HandlerFn> template <typename HandlerFn>
UIInputDispatchSummary Dispatch( UIInputDispatchSummary Dispatch(
const UIInputEvent& event, const UIInputEvent& event,
@@ -66,15 +78,23 @@ public:
m_focusController.SetActivePath(capturePath.Empty() ? hoveredPath : capturePath); m_focusController.SetActivePath(capturePath.Empty() ? hoveredPath : capturePath);
} }
const UIShortcutContext shortcutContext = { if (ShouldStartActivePathOnKeyDown(event)) {
m_focusController.GetFocusedPath(), m_focusController.SetActivePath(m_focusController.GetFocusedPath());
m_focusController.GetActivePath(), }
hoveredPath };
const UIShortcutContext shortcutContext = BuildEffectiveShortcutContext(hoveredPath);
const UIShortcutMatch shortcutMatch = m_shortcutRegistry.Match(event, shortcutContext); const UIShortcutMatch shortcutMatch = m_shortcutRegistry.Match(event, shortcutContext);
if (shortcutMatch.matched) { if (shortcutMatch.matched) {
summary.shortcutHandled = true; summary.shortcutMatched = true;
summary.commandId = shortcutMatch.binding.commandId; summary.commandId = shortcutMatch.binding.commandId;
return FinalizeDispatch(event, std::move(summary)); summary.shortcutScope = shortcutMatch.binding.scope;
summary.shortcutOwnerId = shortcutMatch.binding.ownerId;
if (ShouldSuppressShortcutMatch(event, shortcutMatch, shortcutContext)) {
summary.shortcutSuppressed = true;
} else {
summary.shortcutHandled = true;
return FinalizeDispatch(event, std::move(summary));
}
} }
UIInputRouteContext routeContext = {}; UIInputRouteContext routeContext = {};
@@ -97,6 +117,20 @@ private:
const UIInputEvent& event, const UIInputEvent& event,
const UIInputPath& hoveredPath) const; const UIInputPath& hoveredPath) const;
bool ShouldStartActivePathOnKeyDown(
const UIInputEvent& event) const;
bool ShouldClearActivePathOnKeyUp(
const UIInputEvent& event) const;
UIShortcutContext BuildEffectiveShortcutContext(
const UIInputPath& hoveredPath) const;
bool ShouldSuppressShortcutMatch(
const UIInputEvent& event,
const UIShortcutMatch& shortcutMatch,
const UIShortcutContext& shortcutContext) const;
UIInputDispatchSummary FinalizeDispatch( UIInputDispatchSummary FinalizeDispatch(
const UIInputEvent& event, const UIInputEvent& event,
UIInputDispatchSummary&& summary); UIInputDispatchSummary&& summary);
@@ -104,6 +138,7 @@ private:
UIInputDispatcherOptions m_options = {}; UIInputDispatcherOptions m_options = {};
UIFocusController m_focusController = {}; UIFocusController m_focusController = {};
UIShortcutRegistry m_shortcutRegistry = {}; UIShortcutRegistry m_shortcutRegistry = {};
UIShortcutContext m_shortcutContext = {};
}; };
} // namespace UI } // namespace UI

View File

@@ -33,10 +33,19 @@ struct UIShortcutBinding {
std::string commandId = {}; std::string commandId = {};
}; };
struct UIShortcutScopeChain {
UIInputPath path = {};
UIElementId windowId = 0;
UIElementId panelId = 0;
UIElementId widgetId = 0;
};
struct UIShortcutContext { struct UIShortcutContext {
UIInputPath focusedPath = {}; UIInputPath focusedPath = {};
UIInputPath activePath = {}; UIInputPath activePath = {};
UIInputPath hoveredPath = {}; UIInputPath hoveredPath = {};
UIShortcutScopeChain commandScope = {};
bool textInputActive = false;
}; };
struct UIShortcutMatch { struct UIShortcutMatch {

View File

@@ -0,0 +1,275 @@
#pragma once
#include <XCEngine/UI/Layout/LayoutTypes.h>
#include <algorithm>
#include <cmath>
namespace XCEngine {
namespace UI {
namespace Layout {
struct UISplitterMetrics {
float thickness = 8.0f;
float hitThickness = 12.0f;
};
struct UISplitterConstraints {
float primaryMin = 0.0f;
float primaryMax = GetUnboundedLayoutExtent();
float secondaryMin = 0.0f;
float secondaryMax = GetUnboundedLayoutExtent();
};
struct UISplitterLayoutOptions {
UILayoutAxis axis = UILayoutAxis::Horizontal;
float ratio = 0.5f;
float handleThickness = 8.0f;
float minPrimaryExtent = 0.0f;
float minSecondaryExtent = 0.0f;
};
struct UISplitterLayoutResult {
UIRect primaryRect = {};
UIRect handleRect = {};
UIRect secondaryRect = {};
float resolvedRatio = 0.5f;
float splitRatio = 0.5f;
float primaryExtent = 0.0f;
float secondaryExtent = 0.0f;
};
namespace SplitterDetail {
inline float ClampSplitterExtent(float value) {
return (std::max)(0.0f, value);
}
inline float ClampFiniteExtent(float value, float minValue, float maxValue) {
return (std::clamp)(value, minValue, maxValue);
}
inline float GetMainExtent(const UISize& size, UILayoutAxis axis) {
return axis == UILayoutAxis::Horizontal ? size.width : size.height;
}
inline float GetCrossExtent(const UISize& size, UILayoutAxis axis) {
return axis == UILayoutAxis::Horizontal ? size.height : size.width;
}
inline float GetMainExtent(const UIRect& rect, UILayoutAxis axis) {
return axis == UILayoutAxis::Horizontal ? rect.width : rect.height;
}
inline float GetCrossExtent(const UIRect& rect, UILayoutAxis axis) {
return axis == UILayoutAxis::Horizontal ? rect.height : rect.width;
}
} // namespace SplitterDetail
inline UISize MeasureSplitterDesiredSize(
UILayoutAxis axis,
const UISize& primarySize,
const UISize& secondarySize,
float handleThickness) {
const float clampedHandleThickness = SplitterDetail::ClampSplitterExtent(handleThickness);
if (axis == UILayoutAxis::Horizontal) {
return UISize(
primarySize.width + clampedHandleThickness + secondarySize.width,
(std::max)(primarySize.height, secondarySize.height));
}
return UISize(
(std::max)(primarySize.width, secondarySize.width),
primarySize.height + clampedHandleThickness + secondarySize.height);
}
inline float ClampSplitterRatio(
const UISplitterLayoutOptions& options,
float totalMainExtent) {
const float mainExtent = SplitterDetail::ClampSplitterExtent(totalMainExtent);
const float handleThickness = (std::min)(
SplitterDetail::ClampSplitterExtent(options.handleThickness),
mainExtent);
const float usableExtent = (std::max)(0.0f, mainExtent - handleThickness);
if (usableExtent <= 0.0f) {
return 0.5f;
}
const float requestedPrimaryExtent =
usableExtent * (std::clamp)(options.ratio, 0.0f, 1.0f);
const float minPrimaryExtent = (std::min)(
SplitterDetail::ClampSplitterExtent(options.minPrimaryExtent),
usableExtent);
const float minSecondaryExtent = (std::min)(
SplitterDetail::ClampSplitterExtent(options.minSecondaryExtent),
usableExtent);
float minimumPrimaryExtent = minPrimaryExtent;
float maximumPrimaryExtent = usableExtent - minSecondaryExtent;
if (minimumPrimaryExtent > maximumPrimaryExtent) {
minimumPrimaryExtent = 0.0f;
maximumPrimaryExtent = usableExtent;
}
const float clampedPrimaryExtent = (std::clamp)(
requestedPrimaryExtent,
minimumPrimaryExtent,
maximumPrimaryExtent);
return clampedPrimaryExtent / usableExtent;
}
inline float ClampSplitterRatio(
UILayoutAxis axis,
float requestedRatio,
float totalMainExtent,
const UISplitterConstraints& constraints,
const UISplitterMetrics& metrics) {
UISplitterLayoutOptions options = {};
options.axis = axis;
options.ratio = requestedRatio;
options.handleThickness = metrics.thickness;
options.minPrimaryExtent = constraints.primaryMin;
options.minSecondaryExtent = constraints.secondaryMin;
const float mainExtent = SplitterDetail::ClampSplitterExtent(totalMainExtent);
const float handleThickness = (std::min)(
SplitterDetail::ClampSplitterExtent(metrics.thickness),
mainExtent);
const float usableExtent = (std::max)(0.0f, mainExtent - handleThickness);
if (usableExtent <= 0.0f) {
return 0.5f;
}
const float requestedPrimaryExtent =
usableExtent * (std::clamp)(requestedRatio, 0.0f, 1.0f);
float minimumPrimaryExtent = (std::min)(
SplitterDetail::ClampSplitterExtent(constraints.primaryMin),
usableExtent);
float maximumPrimaryExtent = std::isfinite(constraints.primaryMax)
? (std::clamp)(constraints.primaryMax, minimumPrimaryExtent, usableExtent)
: usableExtent;
const float minimumFromSecondary = (std::max)(
0.0f,
usableExtent - (std::isfinite(constraints.secondaryMax)
? (std::max)(SplitterDetail::ClampSplitterExtent(constraints.secondaryMax), SplitterDetail::ClampSplitterExtent(constraints.secondaryMin))
: usableExtent));
const float maximumFromSecondary = (std::max)(
0.0f,
usableExtent - (std::min)(SplitterDetail::ClampSplitterExtent(constraints.secondaryMin), usableExtent));
minimumPrimaryExtent = (std::max)(minimumPrimaryExtent, minimumFromSecondary);
maximumPrimaryExtent = (std::min)(maximumPrimaryExtent, maximumFromSecondary);
if (minimumPrimaryExtent > maximumPrimaryExtent) {
minimumPrimaryExtent = 0.0f;
maximumPrimaryExtent = usableExtent;
}
const float clampedPrimaryExtent = (std::clamp)(
requestedPrimaryExtent,
minimumPrimaryExtent,
maximumPrimaryExtent);
return clampedPrimaryExtent / usableExtent;
}
inline UISplitterLayoutResult ArrangeSplitterLayout(
const UISplitterLayoutOptions& options,
const UIRect& bounds) {
UISplitterLayoutResult result = {};
const float mainExtent = SplitterDetail::GetMainExtent(bounds, options.axis);
const float crossExtent = SplitterDetail::GetCrossExtent(bounds, options.axis);
const float handleThickness = (std::min)(
SplitterDetail::ClampSplitterExtent(options.handleThickness),
mainExtent);
const float usableExtent = (std::max)(0.0f, mainExtent - handleThickness);
result.resolvedRatio = ClampSplitterRatio(options, mainExtent);
result.primaryExtent = usableExtent * result.resolvedRatio;
result.secondaryExtent = (std::max)(0.0f, usableExtent - result.primaryExtent);
if (options.axis == UILayoutAxis::Horizontal) {
result.primaryRect = UIRect(
bounds.x,
bounds.y,
result.primaryExtent,
crossExtent);
result.handleRect = UIRect(
bounds.x + result.primaryExtent,
bounds.y,
handleThickness,
crossExtent);
result.secondaryRect = UIRect(
result.handleRect.x + handleThickness,
bounds.y,
result.secondaryExtent,
crossExtent);
} else {
result.primaryRect = UIRect(
bounds.x,
bounds.y,
crossExtent,
result.primaryExtent);
result.handleRect = UIRect(
bounds.x,
bounds.y + result.primaryExtent,
crossExtent,
handleThickness);
result.secondaryRect = UIRect(
bounds.x,
result.handleRect.y + handleThickness,
crossExtent,
result.secondaryExtent);
}
result.splitRatio = result.resolvedRatio;
return result;
}
inline float ResolveSplitterRatioFromPointerPosition(
const UISplitterLayoutOptions& options,
const UIRect& bounds,
float pointerMainPosition) {
const float mainExtent = SplitterDetail::GetMainExtent(bounds, options.axis);
const float handleThickness = (std::min)(
SplitterDetail::ClampSplitterExtent(options.handleThickness),
mainExtent);
const float usableExtent = (std::max)(0.0f, mainExtent - handleThickness);
if (usableExtent <= 0.0f) {
return 0.5f;
}
const float origin = options.axis == UILayoutAxis::Horizontal ? bounds.x : bounds.y;
UISplitterLayoutOptions pointerOptions = options;
pointerOptions.ratio = (pointerMainPosition - origin - handleThickness * 0.5f) / usableExtent;
return ClampSplitterRatio(pointerOptions, mainExtent);
}
inline UISplitterLayoutResult ArrangeUISplitter(
const UIRect& bounds,
UILayoutAxis axis,
float requestedRatio,
const UISplitterConstraints& constraints,
const UISplitterMetrics& metrics) {
UISplitterLayoutOptions options = {};
options.axis = axis;
options.ratio = ClampSplitterRatio(
axis,
requestedRatio,
SplitterDetail::GetMainExtent(bounds, axis),
constraints,
metrics);
options.handleThickness = metrics.thickness;
options.minPrimaryExtent = constraints.primaryMin;
options.minSecondaryExtent = constraints.secondaryMin;
UISplitterLayoutResult result = ArrangeSplitterLayout(options, bounds);
result.splitRatio = options.ratio;
result.resolvedRatio = options.ratio;
return result;
}
} // namespace Layout
} // namespace UI
} // namespace XCEngine

View File

@@ -2,6 +2,7 @@
#include <XCEngine/UI/Input/UIInputDispatcher.h> #include <XCEngine/UI/Input/UIInputDispatcher.h>
#include <XCEngine/UI/Runtime/UIScreenTypes.h> #include <XCEngine/UI/Runtime/UIScreenTypes.h>
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
#include <cstdint> #include <cstdint>
#include <string> #include <string>
@@ -27,13 +28,28 @@ public:
std::uint64_t totalPointerEventCount = 0u; std::uint64_t totalPointerEventCount = 0u;
UIPoint pointerPosition = {}; UIPoint pointerPosition = {};
bool pointerInsideViewport = false; bool pointerInsideViewport = false;
bool textInputActive = false;
std::string hoveredStateKey = {}; std::string hoveredStateKey = {};
std::string focusedStateKey = {}; std::string focusedStateKey = {};
std::string activeStateKey = {}; std::string activeStateKey = {};
std::string captureStateKey = {}; std::string captureStateKey = {};
std::string commandScopeStateKey = {};
std::string windowScopeStateKey = {};
std::string panelScopeStateKey = {};
std::string widgetScopeStateKey = {};
std::string lastEventType = {}; std::string lastEventType = {};
std::string lastTargetStateKey = {}; std::string lastTargetStateKey = {};
std::string lastTargetKind = {}; std::string lastTargetKind = {};
std::string lastShortcutCommandId = {};
std::string lastShortcutScope = {};
std::string lastShortcutOwnerStateKey = {};
bool lastShortcutHandled = false;
bool lastShortcutSuppressed = false;
std::string recentShortcutCommandId = {};
std::string recentShortcutScope = {};
std::string recentShortcutOwnerStateKey = {};
bool recentShortcutHandled = false;
bool recentShortcutSuppressed = false;
std::string lastResult = {}; std::string lastResult = {};
}; };
@@ -60,6 +76,11 @@ public:
const InputDebugSnapshot& GetInputDebugSnapshot() const; const InputDebugSnapshot& GetInputDebugSnapshot() const;
const ScrollDebugSnapshot& GetScrollDebugSnapshot() const; const ScrollDebugSnapshot& GetScrollDebugSnapshot() const;
struct SplitterDragRuntimeState {
std::string stateKey = {};
Widgets::UISplitterDragState drag = {};
};
private: private:
struct PointerState { struct PointerState {
UIPoint position = {}; UIPoint position = {};
@@ -69,7 +90,9 @@ private:
UIInputDispatcher m_inputDispatcher; UIInputDispatcher m_inputDispatcher;
std::unordered_map<std::string, float> m_verticalScrollOffsets = {}; std::unordered_map<std::string, float> m_verticalScrollOffsets = {};
std::unordered_map<std::string, float> m_splitterRatios = {};
PointerState m_pointerState = {}; PointerState m_pointerState = {};
SplitterDragRuntimeState m_splitterDragState = {};
InputDebugSnapshot m_inputDebugSnapshot = {}; InputDebugSnapshot m_inputDebugSnapshot = {};
ScrollDebugSnapshot m_scrollDebugSnapshot = {}; ScrollDebugSnapshot m_scrollDebugSnapshot = {};
}; };

View File

@@ -6,7 +6,7 @@ namespace XCEngine {
namespace UI { namespace UI {
enum class UITextureHandleKind : std::uint8_t { enum class UITextureHandleKind : std::uint8_t {
ImGuiDescriptor = 0, DescriptorHandle = 0,
ShaderResourceView ShaderResourceView
}; };
@@ -51,7 +51,7 @@ struct UITextureHandle {
std::uintptr_t nativeHandle = 0; std::uintptr_t nativeHandle = 0;
std::uint32_t width = 0; std::uint32_t width = 0;
std::uint32_t height = 0; std::uint32_t height = 0;
UITextureHandleKind kind = UITextureHandleKind::ImGuiDescriptor; UITextureHandleKind kind = UITextureHandleKind::DescriptorHandle;
constexpr bool IsValid() const { constexpr bool IsValid() const {
return nativeHandle != 0 && width > 0 && height > 0; return nativeHandle != 0 && width > 0 && height > 0;

View File

@@ -0,0 +1,124 @@
#pragma once
#include <XCEngine/UI/Core/UIInvalidation.h>
#include <XCEngine/UI/Layout/UISplitterLayout.h>
#include <cmath>
namespace XCEngine {
namespace UI {
namespace Widgets {
struct UISplitterDragState {
bool active = false;
UIElementId ownerId = 0u;
Layout::UILayoutAxis axis = Layout::UILayoutAxis::Horizontal;
UIRect bounds = {};
Layout::UISplitterConstraints constraints = {};
Layout::UISplitterMetrics metrics = {};
float splitRatio = 0.5f;
};
inline UIRect ExpandUISplitterHandleHitRect(
const UIRect& handleRect,
Layout::UILayoutAxis axis,
float crossAxisPadding = 3.0f) {
const float padding = (std::max)(0.0f, crossAxisPadding);
if (axis == Layout::UILayoutAxis::Horizontal) {
return UIRect(
handleRect.x - padding,
handleRect.y,
handleRect.width + padding * 2.0f,
handleRect.height);
}
return UIRect(
handleRect.x,
handleRect.y - padding,
handleRect.width,
handleRect.height + padding * 2.0f);
}
inline bool HitTestUISplitterHandle(
const UIRect& handleRect,
Layout::UILayoutAxis axis,
const UIPoint& point,
float crossAxisPadding = 3.0f) {
const UIRect hitRect = ExpandUISplitterHandleHitRect(handleRect, axis, crossAxisPadding);
return point.x >= hitRect.x &&
point.x <= hitRect.x + hitRect.width &&
point.y >= hitRect.y &&
point.y <= hitRect.y + hitRect.height;
}
inline bool BeginUISplitterDrag(
UIElementId ownerId,
Layout::UILayoutAxis axis,
const UIRect& bounds,
const Layout::UISplitterLayoutResult& currentLayout,
const Layout::UISplitterConstraints& constraints,
const Layout::UISplitterMetrics& metrics,
const UIPoint& pointerPosition,
UISplitterDragState& outState) {
const float extraHitPadding = (std::max)(
0.0f,
(metrics.hitThickness - metrics.thickness) * 0.5f);
if (!HitTestUISplitterHandle(
currentLayout.handleRect,
axis,
pointerPosition,
extraHitPadding)) {
return false;
}
outState = {};
outState.active = true;
outState.ownerId = ownerId;
outState.axis = axis;
outState.bounds = bounds;
outState.constraints = constraints;
outState.metrics = metrics;
outState.splitRatio = currentLayout.splitRatio;
return true;
}
inline bool UpdateUISplitterDrag(
UISplitterDragState& state,
const UIPoint& pointerPosition,
Layout::UISplitterLayoutResult& outLayout) {
if (!state.active) {
return false;
}
const float pointerMainPosition = state.axis == Layout::UILayoutAxis::Horizontal
? pointerPosition.x
: pointerPosition.y;
const float requestedRatio = Layout::ResolveSplitterRatioFromPointerPosition(
Layout::UISplitterLayoutOptions {
state.axis,
state.splitRatio,
state.metrics.thickness,
state.constraints.primaryMin,
state.constraints.secondaryMin
},
state.bounds,
pointerMainPosition);
outLayout = Layout::ArrangeUISplitter(
state.bounds,
state.axis,
requestedRatio,
state.constraints,
state.metrics);
const bool changed = std::fabs(outLayout.splitRatio - state.splitRatio) > 0.0001f;
state.splitRatio = outLayout.splitRatio;
return changed;
}
inline void EndUISplitterDrag(UISplitterDragState& state) {
state = {};
}
} // namespace Widgets
} // namespace UI
} // namespace XCEngine

View File

@@ -1,8 +1,19 @@
#include <XCEngine/UI/Input/UIInputDispatcher.h> #include <XCEngine/UI/Input/UIInputDispatcher.h>
#include <XCEngine/Input/InputTypes.h>
namespace XCEngine { namespace XCEngine {
namespace UI { namespace UI {
namespace {
bool IsKeyboardActivationKey(std::int32_t keyCode) {
return keyCode == static_cast<std::int32_t>(Input::KeyCode::Enter) ||
keyCode == static_cast<std::int32_t>(Input::KeyCode::Space);
}
} // namespace
bool UIInputDispatcher::ShouldTransferFocusOnPointerDown( bool UIInputDispatcher::ShouldTransferFocusOnPointerDown(
const UIInputEvent& event, const UIInputEvent& event,
const UIInputPath& hoveredPath) const { const UIInputPath& hoveredPath) const {
@@ -20,10 +31,52 @@ bool UIInputDispatcher::ShouldStartActivePathOnPointerDown(
!hoveredPath.Empty(); !hoveredPath.Empty();
} }
bool UIInputDispatcher::ShouldStartActivePathOnKeyDown(
const UIInputEvent& event) const {
return event.type == UIInputEventType::KeyDown &&
!m_focusController.GetFocusedPath().Empty() &&
IsKeyboardActivationKey(event.keyCode);
}
bool UIInputDispatcher::ShouldClearActivePathOnKeyUp(
const UIInputEvent& event) const {
return event.type == UIInputEventType::KeyUp &&
IsKeyboardActivationKey(event.keyCode);
}
UIShortcutContext UIInputDispatcher::BuildEffectiveShortcutContext(
const UIInputPath& hoveredPath) const {
UIShortcutContext context = m_shortcutContext;
context.focusedPath = m_focusController.GetFocusedPath();
context.activePath = m_focusController.GetActivePath();
context.hoveredPath = hoveredPath;
if (context.commandScope.path.Empty()) {
context.commandScope.path = !context.focusedPath.Empty()
? context.focusedPath
: context.activePath;
}
if (context.commandScope.widgetId == 0 &&
!context.commandScope.path.Empty()) {
context.commandScope.widgetId = context.commandScope.path.Target();
}
return context;
}
bool UIInputDispatcher::ShouldSuppressShortcutMatch(
const UIInputEvent&,
const UIShortcutMatch& shortcutMatch,
const UIShortcutContext& shortcutContext) const {
return shortcutMatch.matched && shortcutContext.textInputActive;
}
UIInputDispatchSummary UIInputDispatcher::FinalizeDispatch( UIInputDispatchSummary UIInputDispatcher::FinalizeDispatch(
const UIInputEvent& event, const UIInputEvent& event,
UIInputDispatchSummary&& summary) { UIInputDispatchSummary&& summary) {
if (event.type == UIInputEventType::PointerButtonUp) { if (event.type == UIInputEventType::PointerButtonUp ||
ShouldClearActivePathOnKeyUp(event)) {
m_focusController.ClearActivePath(); m_focusController.ClearActivePath();
} }

View File

@@ -6,14 +6,18 @@ namespace UI {
namespace { namespace {
const UIInputPath& ResolvePrimaryShortcutPath(const UIShortcutContext& context) { const UIInputPath& ResolvePrimaryShortcutPath(const UIShortcutContext& context) {
if (!context.activePath.Empty()) { if (!context.commandScope.path.Empty()) {
return context.activePath; return context.commandScope.path;
} }
if (!context.focusedPath.Empty()) { if (!context.focusedPath.Empty()) {
return context.focusedPath; return context.focusedPath;
} }
if (!context.activePath.Empty()) {
return context.activePath;
}
return context.hoveredPath; return context.hoveredPath;
} }
@@ -26,6 +30,28 @@ bool ModifiersEqual(
lhs.super == rhs.super; lhs.super == rhs.super;
} }
UIElementId ResolveScopedOwnerId(
UIShortcutScope scope,
const UIShortcutContext& context) {
switch (scope) {
case UIShortcutScope::Window:
return context.commandScope.windowId;
case UIShortcutScope::Panel:
return context.commandScope.panelId;
case UIShortcutScope::Widget:
if (context.commandScope.widgetId != 0) {
return context.commandScope.widgetId;
}
return context.commandScope.path.Empty()
? 0
: context.commandScope.path.Target();
case UIShortcutScope::Global:
default:
return 0;
}
}
bool IsBindingActive( bool IsBindingActive(
const UIShortcutBinding& binding, const UIShortcutBinding& binding,
const UIShortcutContext& context) { const UIShortcutContext& context) {
@@ -37,6 +63,11 @@ bool IsBindingActive(
return false; return false;
} }
const UIElementId resolvedOwnerId = ResolveScopedOwnerId(binding.scope, context);
if (resolvedOwnerId != 0) {
return binding.ownerId == resolvedOwnerId;
}
return ResolvePrimaryShortcutPath(context).Contains(binding.ownerId); return ResolvePrimaryShortcutPath(context).Contains(binding.ownerId);
} }
@@ -91,6 +122,7 @@ bool UIShortcutRegistry::UnregisterBinding(std::uint64_t bindingId) {
void UIShortcutRegistry::Clear() { void UIShortcutRegistry::Clear() {
m_bindings.clear(); m_bindings.clear();
m_nextBindingId = 1;
} }
UIShortcutMatch UIShortcutRegistry::Match( UIShortcutMatch UIShortcutRegistry::Match(

File diff suppressed because it is too large Load Diff

View File

@@ -9,15 +9,16 @@ file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCNEWEDITOR_REPO_ROOT_PATH)
set(NEW_EDITOR_RESOURCE_FILES set(NEW_EDITOR_RESOURCE_FILES
ui/views/editor_shell.xcui ui/views/editor_shell.xcui
ui/themes/editor_shell.xctheme ui/themes/editor_shell.xctheme
ui/schemas/editor_inspector_shell.xcschema
) )
add_library(XCNewEditorLib STATIC add_library(XCNewEditorLib STATIC
src/SandboxFrameBuilder.cpp src/editor/EditorShellAsset.cpp
src/Widgets/UIEditorCollectionPrimitives.cpp
) )
target_include_directories(XCNewEditorLib target_include_directories(XCNewEditorLib
PUBLIC PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/engine/include ${CMAKE_SOURCE_DIR}/engine/include
) )
@@ -25,6 +26,7 @@ target_include_directories(XCNewEditorLib
target_compile_definitions(XCNewEditorLib PUBLIC target_compile_definitions(XCNewEditorLib PUBLIC
UNICODE UNICODE
_UNICODE _UNICODE
XCNEWEDITOR_REPO_ROOT="${XCNEWEDITOR_REPO_ROOT_PATH}"
) )
if(MSVC) if(MSVC)
@@ -37,41 +39,70 @@ target_link_libraries(XCNewEditorLib PUBLIC
XCEngine XCEngine
) )
add_executable(XCNewEditorApp WIN32 add_library(XCNewEditorHost STATIC
src/main.cpp src/Host/AutoScreenshot.cpp
src/Application.cpp src/Host/NativeRenderer.cpp
src/AutoScreenshot.cpp
src/NativeRenderer.cpp
${NEW_EDITOR_RESOURCE_FILES}
) )
target_include_directories(XCNewEditorApp PRIVATE target_include_directories(XCNewEditorHost
${CMAKE_CURRENT_SOURCE_DIR}/src PUBLIC
${CMAKE_SOURCE_DIR}/engine/include ${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/engine/include
) )
target_compile_definitions(XCNewEditorApp PRIVATE target_compile_definitions(XCNewEditorHost PUBLIC
UNICODE UNICODE
_UNICODE _UNICODE
XCNEWEDITOR_REPO_ROOT="${XCNEWEDITOR_REPO_ROOT_PATH}"
) )
if(MSVC) if(MSVC)
target_compile_options(XCNewEditorApp PRIVATE /utf-8 /FS) target_compile_options(XCNewEditorHost PRIVATE /utf-8 /FS)
set_property(TARGET XCNewEditorApp PROPERTY set_property(TARGET XCNewEditorHost PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL") MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif() endif()
target_link_libraries(XCNewEditorApp PRIVATE target_link_libraries(XCNewEditorHost PUBLIC
XCNewEditorLib XCEngine
d2d1.lib d2d1.lib
dwrite.lib dwrite.lib
windowscodecs.lib windowscodecs.lib
) )
set_target_properties(XCNewEditorApp PROPERTIES if(XCENGINE_BUILD_NEW_EDITOR)
OUTPUT_NAME "XCNewEditor" add_executable(XCNewEditorApp WIN32
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/bin" src/main.cpp
) src/Host/Application.cpp
${NEW_EDITOR_RESOURCE_FILES}
)
target_include_directories(XCNewEditorApp PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(XCNewEditorApp PRIVATE
UNICODE
_UNICODE
XCNEWEDITOR_REPO_ROOT="${XCNEWEDITOR_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(XCNewEditorApp PRIVATE /utf-8 /FS)
set_property(TARGET XCNewEditorApp PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(XCNewEditorApp PRIVATE
XCNewEditorLib
XCNewEditorHost
)
set_target_properties(XCNewEditorApp PROPERTIES
OUTPUT_NAME "XCNewEditor"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/bin"
)
endif()
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES ${NEW_EDITOR_RESOURCE_FILES}) source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES ${NEW_EDITOR_RESOURCE_FILES})

View File

@@ -11,8 +11,7 @@
#include <string> #include <string>
#include <string_view> #include <string_view>
namespace XCEngine { namespace XCEngine::XCUI::Host {
namespace NewEditor {
class NativeRenderer; class NativeRenderer;
@@ -50,5 +49,4 @@ private:
bool m_capturePending = false; bool m_capturePending = false;
}; };
} // namespace NewEditor } // namespace XCEngine::XCUI::Host
} // namespace XCEngine

View File

@@ -0,0 +1,173 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEngine/UI/Types.h>
#include <windows.h>
#include <cstddef>
#include <cstdint>
namespace XCEngine::XCUI::Host {
class InputModifierTracker {
public:
void Reset() {
m_leftShift = false;
m_rightShift = false;
m_leftControl = false;
m_rightControl = false;
m_leftAlt = false;
m_rightAlt = false;
m_leftSuper = false;
m_rightSuper = false;
}
void SyncFromSystemState() {
m_leftShift = (GetKeyState(VK_LSHIFT) & 0x8000) != 0;
m_rightShift = (GetKeyState(VK_RSHIFT) & 0x8000) != 0;
m_leftControl = (GetKeyState(VK_LCONTROL) & 0x8000) != 0;
m_rightControl = (GetKeyState(VK_RCONTROL) & 0x8000) != 0;
m_leftAlt = (GetKeyState(VK_LMENU) & 0x8000) != 0;
m_rightAlt = (GetKeyState(VK_RMENU) & 0x8000) != 0;
m_leftSuper = (GetKeyState(VK_LWIN) & 0x8000) != 0;
m_rightSuper = (GetKeyState(VK_RWIN) & 0x8000) != 0;
}
::XCEngine::UI::UIInputModifiers GetCurrentModifiers() const {
return BuildModifiers();
}
::XCEngine::UI::UIInputModifiers BuildPointerModifiers(std::size_t wParam) const {
::XCEngine::UI::UIInputModifiers modifiers = BuildModifiers();
modifiers.shift = modifiers.shift || (wParam & MK_SHIFT) != 0;
modifiers.control = modifiers.control || (wParam & MK_CONTROL) != 0;
return modifiers;
}
::XCEngine::UI::UIInputModifiers ApplyKeyMessage(
::XCEngine::UI::UIInputEventType type,
WPARAM wParam,
LPARAM lParam) {
if (type == ::XCEngine::UI::UIInputEventType::KeyDown) {
SetModifierState(ResolveModifierKey(wParam, lParam), true);
} else if (type == ::XCEngine::UI::UIInputEventType::KeyUp) {
SetModifierState(ResolveModifierKey(wParam, lParam), false);
}
return BuildModifiers();
}
private:
enum class ModifierKey : std::uint8_t {
None = 0,
LeftShift,
RightShift,
LeftControl,
RightControl,
LeftAlt,
RightAlt,
LeftSuper,
RightSuper
};
static bool IsExtendedKey(LPARAM lParam) {
return (static_cast<std::uint32_t>(lParam) & 0x01000000u) != 0u;
}
static std::uint32_t ExtractScanCode(LPARAM lParam) {
return (static_cast<std::uint32_t>(lParam) >> 16u) & 0xffu;
}
static ModifierKey ResolveModifierKey(WPARAM wParam, LPARAM lParam) {
switch (static_cast<std::uint32_t>(wParam)) {
case VK_SHIFT: {
const UINT shiftVirtualKey = MapVirtualKeyW(ExtractScanCode(lParam), MAPVK_VSC_TO_VK_EX);
return shiftVirtualKey == VK_RSHIFT
? ModifierKey::RightShift
: ModifierKey::LeftShift;
}
case VK_LSHIFT:
return ModifierKey::LeftShift;
case VK_RSHIFT:
return ModifierKey::RightShift;
case VK_CONTROL:
return IsExtendedKey(lParam)
? ModifierKey::RightControl
: ModifierKey::LeftControl;
case VK_LCONTROL:
return ModifierKey::LeftControl;
case VK_RCONTROL:
return ModifierKey::RightControl;
case VK_MENU:
return IsExtendedKey(lParam)
? ModifierKey::RightAlt
: ModifierKey::LeftAlt;
case VK_LMENU:
return ModifierKey::LeftAlt;
case VK_RMENU:
return ModifierKey::RightAlt;
case VK_LWIN:
return ModifierKey::LeftSuper;
case VK_RWIN:
return ModifierKey::RightSuper;
default:
return ModifierKey::None;
}
}
void SetModifierState(ModifierKey key, bool pressed) {
switch (key) {
case ModifierKey::LeftShift:
m_leftShift = pressed;
break;
case ModifierKey::RightShift:
m_rightShift = pressed;
break;
case ModifierKey::LeftControl:
m_leftControl = pressed;
break;
case ModifierKey::RightControl:
m_rightControl = pressed;
break;
case ModifierKey::LeftAlt:
m_leftAlt = pressed;
break;
case ModifierKey::RightAlt:
m_rightAlt = pressed;
break;
case ModifierKey::LeftSuper:
m_leftSuper = pressed;
break;
case ModifierKey::RightSuper:
m_rightSuper = pressed;
break;
case ModifierKey::None:
default:
break;
}
}
::XCEngine::UI::UIInputModifiers BuildModifiers() const {
::XCEngine::UI::UIInputModifiers modifiers = {};
modifiers.shift = m_leftShift || m_rightShift;
modifiers.control = m_leftControl || m_rightControl;
modifiers.alt = m_leftAlt || m_rightAlt;
modifiers.super = m_leftSuper || m_rightSuper;
return modifiers;
}
bool m_leftShift = false;
bool m_rightShift = false;
bool m_leftControl = false;
bool m_rightControl = false;
bool m_leftAlt = false;
bool m_rightAlt = false;
bool m_leftSuper = false;
bool m_rightSuper = false;
};
} // namespace XCEngine::XCUI::Host

View File

@@ -18,8 +18,7 @@
#include <unordered_map> #include <unordered_map>
#include <vector> #include <vector>
namespace XCEngine { namespace XCEngine::XCUI::Host {
namespace NewEditor {
class NativeRenderer { class NativeRenderer {
public: public:
@@ -63,5 +62,4 @@ private:
bool m_wicComInitialized = false; bool m_wicComInitialized = false;
}; };
} // namespace NewEditor } // namespace XCEngine::XCUI::Host
} // namespace XCEngine

View File

@@ -1,6 +1,6 @@
#include "Application.h" #include "Application.h"
#include "SandboxFrameBuilder.h" #include <XCEngine/Input/InputTypes.h>
#include <algorithm> #include <algorithm>
#include <chrono> #include <chrono>
@@ -15,8 +15,7 @@
#define XCNEWEDITOR_REPO_ROOT "." #define XCNEWEDITOR_REPO_ROOT "."
#endif #endif
namespace XCEngine { namespace XCEngine::NewEditor {
namespace NewEditor {
namespace { namespace {
@@ -25,14 +24,14 @@ using ::XCEngine::UI::UIDrawData;
using ::XCEngine::UI::UIDrawList; using ::XCEngine::UI::UIDrawList;
using ::XCEngine::UI::UIInputEvent; using ::XCEngine::UI::UIInputEvent;
using ::XCEngine::UI::UIInputEventType; using ::XCEngine::UI::UIInputEventType;
using ::XCEngine::UI::UIInputModifiers;
using ::XCEngine::UI::UIPoint; using ::XCEngine::UI::UIPoint;
using ::XCEngine::UI::UIPointerButton; using ::XCEngine::UI::UIPointerButton;
using ::XCEngine::UI::UIRect; using ::XCEngine::UI::UIRect;
using ::XCEngine::UI::Runtime::UIScreenFrameInput; using ::XCEngine::UI::Runtime::UIScreenFrameInput;
using ::XCEngine::Input::KeyCode;
constexpr const wchar_t* kWindowClassName = L"XCNewEditorNativeSandbox"; constexpr const wchar_t* kWindowClassName = L"XCNewEditorShellHost";
constexpr const wchar_t* kWindowTitle = L"XCNewEditor Native Sandbox"; constexpr const wchar_t* kWindowTitle = L"XCUI New Editor";
constexpr auto kReloadPollInterval = std::chrono::milliseconds(150); constexpr auto kReloadPollInterval = std::chrono::milliseconds(150);
constexpr UIColor kOverlayBgColor(0.10f, 0.10f, 0.10f, 0.95f); constexpr UIColor kOverlayBgColor(0.10f, 0.10f, 0.10f, 0.95f);
@@ -83,21 +82,79 @@ std::string FormatPoint(const UIPoint& point) {
return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")"; return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")";
} }
std::string FormatRect(const UIRect& rect) { std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) {
return "(" + FormatFloat(rect.x) + switch (wParam) {
", " + FormatFloat(rect.y) + case 'A': return static_cast<std::int32_t>(KeyCode::A);
", " + FormatFloat(rect.width) + case 'B': return static_cast<std::int32_t>(KeyCode::B);
", " + FormatFloat(rect.height) + case 'C': return static_cast<std::int32_t>(KeyCode::C);
")"; case 'D': return static_cast<std::int32_t>(KeyCode::D);
case 'E': return static_cast<std::int32_t>(KeyCode::E);
case 'F': return static_cast<std::int32_t>(KeyCode::F);
case 'G': return static_cast<std::int32_t>(KeyCode::G);
case 'H': return static_cast<std::int32_t>(KeyCode::H);
case 'I': return static_cast<std::int32_t>(KeyCode::I);
case 'J': return static_cast<std::int32_t>(KeyCode::J);
case 'K': return static_cast<std::int32_t>(KeyCode::K);
case 'L': return static_cast<std::int32_t>(KeyCode::L);
case 'M': return static_cast<std::int32_t>(KeyCode::M);
case 'N': return static_cast<std::int32_t>(KeyCode::N);
case 'O': return static_cast<std::int32_t>(KeyCode::O);
case 'P': return static_cast<std::int32_t>(KeyCode::P);
case 'Q': return static_cast<std::int32_t>(KeyCode::Q);
case 'R': return static_cast<std::int32_t>(KeyCode::R);
case 'S': return static_cast<std::int32_t>(KeyCode::S);
case 'T': return static_cast<std::int32_t>(KeyCode::T);
case 'U': return static_cast<std::int32_t>(KeyCode::U);
case 'V': return static_cast<std::int32_t>(KeyCode::V);
case 'W': return static_cast<std::int32_t>(KeyCode::W);
case 'X': return static_cast<std::int32_t>(KeyCode::X);
case 'Y': return static_cast<std::int32_t>(KeyCode::Y);
case 'Z': return static_cast<std::int32_t>(KeyCode::Z);
case '0': return static_cast<std::int32_t>(KeyCode::Zero);
case '1': return static_cast<std::int32_t>(KeyCode::One);
case '2': return static_cast<std::int32_t>(KeyCode::Two);
case '3': return static_cast<std::int32_t>(KeyCode::Three);
case '4': return static_cast<std::int32_t>(KeyCode::Four);
case '5': return static_cast<std::int32_t>(KeyCode::Five);
case '6': return static_cast<std::int32_t>(KeyCode::Six);
case '7': return static_cast<std::int32_t>(KeyCode::Seven);
case '8': return static_cast<std::int32_t>(KeyCode::Eight);
case '9': return static_cast<std::int32_t>(KeyCode::Nine);
case VK_SPACE: return static_cast<std::int32_t>(KeyCode::Space);
case VK_TAB: return static_cast<std::int32_t>(KeyCode::Tab);
case VK_RETURN: return static_cast<std::int32_t>(KeyCode::Enter);
case VK_ESCAPE: return static_cast<std::int32_t>(KeyCode::Escape);
case VK_SHIFT: return static_cast<std::int32_t>(KeyCode::LeftShift);
case VK_CONTROL: return static_cast<std::int32_t>(KeyCode::LeftCtrl);
case VK_MENU: return static_cast<std::int32_t>(KeyCode::LeftAlt);
case VK_UP: return static_cast<std::int32_t>(KeyCode::Up);
case VK_DOWN: return static_cast<std::int32_t>(KeyCode::Down);
case VK_LEFT: return static_cast<std::int32_t>(KeyCode::Left);
case VK_RIGHT: return static_cast<std::int32_t>(KeyCode::Right);
case VK_HOME: return static_cast<std::int32_t>(KeyCode::Home);
case VK_END: return static_cast<std::int32_t>(KeyCode::End);
case VK_PRIOR: return static_cast<std::int32_t>(KeyCode::PageUp);
case VK_NEXT: return static_cast<std::int32_t>(KeyCode::PageDown);
case VK_DELETE: return static_cast<std::int32_t>(KeyCode::Delete);
case VK_BACK: return static_cast<std::int32_t>(KeyCode::Backspace);
case VK_F1: return static_cast<std::int32_t>(KeyCode::F1);
case VK_F2: return static_cast<std::int32_t>(KeyCode::F2);
case VK_F3: return static_cast<std::int32_t>(KeyCode::F3);
case VK_F4: return static_cast<std::int32_t>(KeyCode::F4);
case VK_F5: return static_cast<std::int32_t>(KeyCode::F5);
case VK_F6: return static_cast<std::int32_t>(KeyCode::F6);
case VK_F7: return static_cast<std::int32_t>(KeyCode::F7);
case VK_F8: return static_cast<std::int32_t>(KeyCode::F8);
case VK_F9: return static_cast<std::int32_t>(KeyCode::F9);
case VK_F10: return static_cast<std::int32_t>(KeyCode::F10);
case VK_F11: return static_cast<std::int32_t>(KeyCode::F11);
case VK_F12: return static_cast<std::int32_t>(KeyCode::F12);
default: return static_cast<std::int32_t>(KeyCode::None);
}
} }
UIInputModifiers BuildInputModifiers(size_t wParam) { bool IsRepeatKeyMessage(LPARAM lParam) {
UIInputModifiers modifiers = {}; return (static_cast<unsigned long>(lParam) & (1ul << 30)) != 0ul;
modifiers.shift = (wParam & MK_SHIFT) != 0;
modifiers.control = (wParam & MK_CONTROL) != 0;
modifiers.alt = (GetKeyState(VK_MENU) & 0x8000) != 0;
modifiers.super = (GetKeyState(VK_LWIN) & 0x8000) != 0 || (GetKeyState(VK_RWIN) & 0x8000) != 0;
return modifiers;
} }
} // namespace } // namespace
@@ -130,6 +187,7 @@ int Application::Run(HINSTANCE hInstance, int nCmdShow) {
bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
m_hInstance = hInstance; m_hInstance = hInstance;
m_shellAssetDefinition = BuildDefaultEditorShellAsset(ResolveRepoRootPath());
WNDCLASSEXW windowClass = {}; WNDCLASSEXW windowClass = {};
windowClass.cbSize = sizeof(windowClass); windowClass.cbSize = sizeof(windowClass);
@@ -170,7 +228,7 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
m_startTime = std::chrono::steady_clock::now(); m_startTime = std::chrono::steady_clock::now();
m_lastFrameTime = m_startTime; m_lastFrameTime = m_startTime;
m_autoScreenshot.Initialize(ResolveRepoRelativePath("new_editor/captures")); m_autoScreenshot.Initialize(m_shellAssetDefinition.captureRootPath);
LoadStructuredScreen("startup"); LoadStructuredScreen("startup");
return true; return true;
} }
@@ -209,7 +267,6 @@ void Application::RenderFrame() {
const float height = static_cast<float>((std::max)(clientRect.bottom - clientRect.top, 1L)); const float height = static_cast<float>((std::max)(clientRect.bottom - clientRect.top, 1L));
const auto now = std::chrono::steady_clock::now(); const auto now = std::chrono::steady_clock::now();
const double timeSeconds = std::chrono::duration<double>(now - m_startTime).count();
double deltaTimeSeconds = std::chrono::duration<double>(now - m_lastFrameTime).count(); double deltaTimeSeconds = std::chrono::duration<double>(now - m_lastFrameTime).count();
if (deltaTimeSeconds <= 0.0) { if (deltaTimeSeconds <= 0.0) {
deltaTimeSeconds = 1.0 / 60.0; deltaTimeSeconds = 1.0 / 60.0;
@@ -234,17 +291,10 @@ void Application::RenderFrame() {
drawData.AddDrawList(drawList); drawData.AddDrawList(drawList);
} }
m_runtimeStatus = "Authored XCUI"; m_runtimeStatus = "XCUI Editor Shell";
m_runtimeError = frame.errorMessage; m_runtimeError = frame.errorMessage;
} } else {
m_runtimeStatus = "Editor Shell | Load Error";
if (drawData.Empty()) {
SandboxFrameOptions options = {};
options.width = width;
options.height = height;
options.timeSeconds = timeSeconds;
drawData = BuildSandboxFrame(options);
m_runtimeStatus = "Fallback Sandbox";
if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) { if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) {
m_runtimeError = m_screenPlayer.GetLastError(); m_runtimeError = m_screenPlayer.GetLastError();
} }
@@ -276,7 +326,7 @@ void Application::QueuePointerEvent(UIInputEventType type, UIPointerButton butto
event.position = UIPoint( event.position = UIPoint(
static_cast<float>(GET_X_LPARAM(lParam)), static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam))); static_cast<float>(GET_Y_LPARAM(lParam)));
event.modifiers = BuildInputModifiers(static_cast<size_t>(wParam)); event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<std::size_t>(wParam));
m_pendingInputEvents.push_back(event); m_pendingInputEvents.push_back(event);
} }
@@ -307,19 +357,43 @@ void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM
event.type = UIInputEventType::PointerWheel; event.type = UIInputEventType::PointerWheel;
event.position = UIPoint(static_cast<float>(screenPoint.x), static_cast<float>(screenPoint.y)); event.position = UIPoint(static_cast<float>(screenPoint.x), static_cast<float>(screenPoint.y));
event.wheelDelta = static_cast<float>(wheelDelta); event.wheelDelta = static_cast<float>(wheelDelta);
event.modifiers = BuildInputModifiers(static_cast<size_t>(wParam)); event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<std::size_t>(wParam));
m_pendingInputEvents.push_back(event);
}
void Application::QueueKeyEvent(UIInputEventType type, WPARAM wParam, LPARAM lParam) {
UIInputEvent event = {};
event.type = type;
event.keyCode = MapVirtualKeyToUIKeyCode(wParam);
event.modifiers = m_inputModifierTracker.ApplyKeyMessage(type, wParam, lParam);
event.repeat = IsRepeatKeyMessage(lParam);
m_pendingInputEvents.push_back(event);
}
void Application::QueueCharacterEvent(WPARAM wParam, LPARAM) {
UIInputEvent event = {};
event.type = UIInputEventType::Character;
event.character = static_cast<std::uint32_t>(wParam);
event.modifiers = m_inputModifierTracker.GetCurrentModifiers();
m_pendingInputEvents.push_back(event);
}
void Application::QueueWindowFocusEvent(UIInputEventType type) {
UIInputEvent event = {};
event.type = type;
m_pendingInputEvents.push_back(event); m_pendingInputEvents.push_back(event);
} }
bool Application::LoadStructuredScreen(const char* triggerReason) { bool Application::LoadStructuredScreen(const char* triggerReason) {
(void)triggerReason;
m_screenAsset = {}; m_screenAsset = {};
m_screenAsset.screenId = "new_editor.editor_shell"; m_screenAsset.screenId = m_shellAssetDefinition.screenId;
m_screenAsset.documentPath = ResolveRepoRelativePath("new_editor/ui/views/editor_shell.xcui").string(); m_screenAsset.documentPath = m_shellAssetDefinition.documentPath.string();
m_screenAsset.themePath = ResolveRepoRelativePath("new_editor/ui/themes/editor_shell.xctheme").string(); m_screenAsset.themePath = m_shellAssetDefinition.themePath.string();
const bool loaded = m_screenPlayer.Load(m_screenAsset); const bool loaded = m_screenPlayer.Load(m_screenAsset);
m_useStructuredScreen = loaded; m_useStructuredScreen = loaded;
m_runtimeStatus = loaded ? "Authored XCUI" : "Fallback Sandbox"; m_runtimeStatus = loaded ? "XCUI Editor Shell" : "Editor Shell | Load Error";
m_runtimeError = loaded ? std::string() : m_screenPlayer.GetLastError(); m_runtimeError = loaded ? std::string() : m_screenPlayer.GetLastError();
RebuildTrackedFileStates(); RebuildTrackedFileStates();
return loaded; return loaded;
@@ -404,12 +478,13 @@ bool Application::DetectTrackedFileChange() const {
void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const { void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const {
const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded(); const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded();
const float panelWidth = authoredMode ? 420.0f : 360.0f; const float panelWidth = authoredMode ? 430.0f : 380.0f;
std::vector<std::string> detailLines = {}; std::vector<std::string> detailLines = {};
detailLines.push_back( detailLines.push_back(
authoredMode authoredMode
? "Hot reload watches authored UI resources." ? "Hot reload watches editor shell resources."
: "Using native fallback while authored UI is invalid."); : "Authored editor shell failed to load.");
detailLines.push_back("Document: editor_shell.xcui");
if (authoredMode) { if (authoredMode) {
const auto& inputDebug = m_documentHost.GetInputDebugSnapshot(); const auto& inputDebug = m_documentHost.GetInputDebugSnapshot();
@@ -424,18 +499,24 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
" | " + " | " +
ExtractStateKeyTail(inputDebug.captureStateKey)); ExtractStateKeyTail(inputDebug.captureStateKey));
if (!inputDebug.lastEventType.empty()) { if (!inputDebug.lastEventType.empty()) {
const std::string eventPosition = inputDebug.lastEventType == "KeyDown" ||
inputDebug.lastEventType == "KeyUp" ||
inputDebug.lastEventType == "Character" ||
inputDebug.lastEventType == "FocusGained" ||
inputDebug.lastEventType == "FocusLost"
? std::string()
: " at " + FormatPoint(inputDebug.pointerPosition);
detailLines.push_back( detailLines.push_back(
"Last input: " + "Last input: " +
inputDebug.lastEventType + inputDebug.lastEventType +
" at " + eventPosition);
FormatPoint(inputDebug.pointerPosition));
detailLines.push_back( detailLines.push_back(
"Route: " + "Route: " +
inputDebug.lastTargetKind + inputDebug.lastTargetKind +
" -> " + " -> " +
ExtractStateKeyTail(inputDebug.lastTargetStateKey)); ExtractStateKeyTail(inputDebug.lastTargetStateKey));
detailLines.push_back( detailLines.push_back(
"Result: " + "Last event result: " +
(inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult)); (inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult));
} }
} }
@@ -445,13 +526,15 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
} else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) { } else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) {
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureSummary(), 78u)); detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureSummary(), 78u));
} else { } else {
detailLines.push_back("Screenshots: manual only (F12)"); detailLines.push_back("Screenshots: F12 -> new_editor/captures/");
} }
if (!m_runtimeError.empty()) { if (!m_runtimeError.empty()) {
detailLines.push_back(TruncateText(m_runtimeError, 78u)); detailLines.push_back(TruncateText(m_runtimeError, 78u));
} else if (!m_autoScreenshot.GetLastCaptureError().empty()) { } else if (!m_autoScreenshot.GetLastCaptureError().empty()) {
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureError(), 78u)); detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureError(), 78u));
} else if (!authoredMode) {
detailLines.push_back("No fallback sandbox is rendered in this host.");
} }
const float panelHeight = 38.0f + static_cast<float>(detailLines.size()) * 18.0f; const float panelHeight = 38.0f + static_cast<float>(detailLines.size()) * 18.0f;
@@ -466,7 +549,7 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
4.0f); 4.0f);
overlay.AddText( overlay.AddText(
UIPoint(panelRect.x + 28.0f, panelRect.y + 10.0f), UIPoint(panelRect.x + 28.0f, panelRect.y + 10.0f),
m_runtimeStatus.empty() ? "Runtime State" : m_runtimeStatus, m_runtimeStatus.empty() ? "XCUI Editor Shell" : m_runtimeStatus,
kOverlayTextPrimary, kOverlayTextPrimary,
14.0f); 14.0f);
@@ -484,8 +567,12 @@ void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float
} }
} }
std::filesystem::path Application::ResolveRepoRelativePath(const char* relativePath) { std::filesystem::path Application::ResolveRepoRootPath() {
return (std::filesystem::path(XCNEWEDITOR_REPO_ROOT) / relativePath).lexically_normal(); std::string root = XCNEWEDITOR_REPO_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return std::filesystem::path(root).lexically_normal();
} }
LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
@@ -557,9 +644,40 @@ LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LP
return 0; return 0;
} }
break; break;
case WM_SETFOCUS:
if (application != nullptr) {
application->m_inputModifierTracker.SyncFromSystemState();
application->QueueWindowFocusEvent(UIInputEventType::FocusGained);
return 0;
}
break;
case WM_KILLFOCUS:
if (application != nullptr) {
application->m_inputModifierTracker.Reset();
application->QueueWindowFocusEvent(UIInputEventType::FocusLost);
return 0;
}
break;
case WM_KEYDOWN: case WM_KEYDOWN:
if (application != nullptr && wParam == VK_F12) { case WM_SYSKEYDOWN:
application->m_autoScreenshot.RequestCapture("manual_f12"); if (application != nullptr) {
if (wParam == VK_F12) {
application->m_autoScreenshot.RequestCapture("manual_f12");
}
application->QueueKeyEvent(UIInputEventType::KeyDown, wParam, lParam);
return 0;
}
break;
case WM_KEYUP:
case WM_SYSKEYUP:
if (application != nullptr) {
application->QueueKeyEvent(UIInputEventType::KeyUp, wParam, lParam);
return 0;
}
break;
case WM_CHAR:
if (application != nullptr) {
application->QueueCharacterEvent(wParam, lParam);
return 0; return 0;
} }
break; break;
@@ -579,9 +697,8 @@ LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LP
} }
int RunNewEditor(HINSTANCE hInstance, int nCmdShow) { int RunNewEditor(HINSTANCE hInstance, int nCmdShow) {
Application application = {}; Application application;
return application.Run(hInstance, nCmdShow); return application.Run(hInstance, nCmdShow);
} }
} // namespace NewEditor } // namespace XCEngine::NewEditor
} // namespace XCEngine

View File

@@ -4,8 +4,11 @@
#define NOMINMAX #define NOMINMAX
#endif #endif
#include "AutoScreenshot.h" #include <XCNewEditor/Host/AutoScreenshot.h>
#include "NativeRenderer.h" #include <XCNewEditor/Host/InputModifierTracker.h>
#include <XCNewEditor/Host/NativeRenderer.h>
#include "editor/EditorShellAsset.h"
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h> #include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h> #include <XCEngine/UI/Runtime/UIScreenPlayer.h>
@@ -19,8 +22,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
namespace XCEngine { namespace XCEngine::NewEditor {
namespace NewEditor {
class Application { class Application {
public: public:
@@ -44,27 +46,32 @@ private:
void QueuePointerEvent(::XCEngine::UI::UIInputEventType type, ::XCEngine::UI::UIPointerButton button, WPARAM wParam, LPARAM lParam); void QueuePointerEvent(::XCEngine::UI::UIInputEventType type, ::XCEngine::UI::UIPointerButton button, WPARAM wParam, LPARAM lParam);
void QueuePointerLeaveEvent(); void QueuePointerLeaveEvent();
void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam); void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam);
void QueueKeyEvent(::XCEngine::UI::UIInputEventType type, WPARAM wParam, LPARAM lParam);
void QueueCharacterEvent(WPARAM wParam, LPARAM lParam);
void QueueWindowFocusEvent(::XCEngine::UI::UIInputEventType type);
bool LoadStructuredScreen(const char* triggerReason); bool LoadStructuredScreen(const char* triggerReason);
void RefreshStructuredScreen(); void RefreshStructuredScreen();
void RebuildTrackedFileStates(); void RebuildTrackedFileStates();
bool DetectTrackedFileChange() const; bool DetectTrackedFileChange() const;
void AppendRuntimeOverlay(::XCEngine::UI::UIDrawData& drawData, float width, float height) const; void AppendRuntimeOverlay(::XCEngine::UI::UIDrawData& drawData, float width, float height) const;
static std::filesystem::path ResolveRepoRelativePath(const char* relativePath); static std::filesystem::path ResolveRepoRootPath();
HWND m_hwnd = nullptr; HWND m_hwnd = nullptr;
HINSTANCE m_hInstance = nullptr; HINSTANCE m_hInstance = nullptr;
ATOM m_windowClassAtom = 0; ATOM m_windowClassAtom = 0;
NativeRenderer m_renderer; ::XCEngine::XCUI::Host::NativeRenderer m_renderer;
AutoScreenshotController m_autoScreenshot; ::XCEngine::XCUI::Host::AutoScreenshotController m_autoScreenshot;
::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost; ::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost;
::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer; ::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer;
::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {}; ::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {};
EditorShellAsset m_shellAssetDefinition = {};
std::vector<TrackedFileState> m_trackedFiles = {}; std::vector<TrackedFileState> m_trackedFiles = {};
std::chrono::steady_clock::time_point m_startTime = {}; std::chrono::steady_clock::time_point m_startTime = {};
std::chrono::steady_clock::time_point m_lastFrameTime = {}; std::chrono::steady_clock::time_point m_lastFrameTime = {};
std::chrono::steady_clock::time_point m_lastReloadPollTime = {}; std::chrono::steady_clock::time_point m_lastReloadPollTime = {};
std::uint64_t m_frameIndex = 0; std::uint64_t m_frameIndex = 0;
std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {}; std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {};
::XCEngine::XCUI::Host::InputModifierTracker m_inputModifierTracker = {};
bool m_trackingMouseLeave = false; bool m_trackingMouseLeave = false;
bool m_useStructuredScreen = false; bool m_useStructuredScreen = false;
std::string m_runtimeStatus = {}; std::string m_runtimeStatus = {};
@@ -73,5 +80,4 @@ private:
int RunNewEditor(HINSTANCE hInstance, int nCmdShow); int RunNewEditor(HINSTANCE hInstance, int nCmdShow);
} // namespace NewEditor } // namespace XCEngine::NewEditor
} // namespace XCEngine

View File

@@ -1,6 +1,6 @@
#include "AutoScreenshot.h" #include <XCNewEditor/Host/AutoScreenshot.h>
#include "NativeRenderer.h" #include <XCNewEditor/Host/NativeRenderer.h>
#include <chrono> #include <chrono>
#include <cctype> #include <cctype>
@@ -8,8 +8,7 @@
#include <sstream> #include <sstream>
#include <system_error> #include <system_error>
namespace XCEngine { namespace XCEngine::XCUI::Host {
namespace NewEditor {
void AutoScreenshotController::Initialize(const std::filesystem::path& captureRoot) { void AutoScreenshotController::Initialize(const std::filesystem::path& captureRoot) {
m_captureRoot = captureRoot.lexically_normal(); m_captureRoot = captureRoot.lexically_normal();
@@ -144,5 +143,4 @@ std::string AutoScreenshotController::SanitizeReason(std::string_view reason) {
return sanitized.empty() ? "capture" : sanitized; return sanitized.empty() ? "capture" : sanitized;
} }
} // namespace NewEditor } // namespace XCEngine::XCUI::Host
} // namespace XCEngine

View File

@@ -1,11 +1,10 @@
#include "NativeRenderer.h" #include <XCNewEditor/Host/NativeRenderer.h>
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <filesystem> #include <filesystem>
namespace XCEngine { namespace XCEngine::XCUI::Host {
namespace NewEditor {
namespace { namespace {
@@ -483,5 +482,4 @@ std::wstring NativeRenderer::Utf8ToWide(std::string_view text) {
return wideText; return wideText;
} }
} // namespace NewEditor } // namespace XCEngine::XCUI::Host
} // namespace XCEngine

View File

@@ -1,334 +0,0 @@
#include "SandboxFrameBuilder.h"
#include <XCEngine/UI/Widgets/UIEditorPanelChrome.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <string_view>
namespace XCEngine {
namespace NewEditor {
namespace {
using ::XCEngine::UI::UIColor;
using ::XCEngine::UI::UIDrawData;
using ::XCEngine::UI::UIDrawList;
using ::XCEngine::UI::UIPoint;
using ::XCEngine::UI::UIRect;
using ::XCEngine::UI::Widgets::AppendUIEditorPanelChromeBackground;
using ::XCEngine::UI::Widgets::AppendUIEditorPanelChromeForeground;
using ::XCEngine::UI::Widgets::UIEditorPanelChromeMetrics;
using ::XCEngine::UI::Widgets::UIEditorPanelChromeState;
using ::XCEngine::UI::Widgets::UIEditorPanelChromeText;
constexpr UIColor kWorkspaceColor(0.05f, 0.07f, 0.09f, 1.0f);
constexpr UIColor kShellColor(0.08f, 0.10f, 0.13f, 1.0f);
constexpr UIColor kSubtleLineColor(0.18f, 0.23f, 0.29f, 1.0f);
constexpr UIColor kTextPrimary(0.92f, 0.95f, 0.98f, 1.0f);
constexpr UIColor kTextSecondary(0.67f, 0.74f, 0.81f, 1.0f);
constexpr UIColor kTextMuted(0.49f, 0.56f, 0.64f, 1.0f);
constexpr UIColor kSelectionColor(0.16f, 0.37f, 0.64f, 0.90f);
constexpr UIColor kAccentColor(0.19f, 0.76f, 0.57f, 1.0f);
constexpr UIColor kWarningColor(0.96f, 0.72f, 0.22f, 1.0f);
constexpr UIColor kErrorColor(0.88f, 0.29f, 0.30f, 1.0f);
float ClampPositive(float value, float fallback) {
return value > 1.0f ? value : fallback;
}
float Pulse01(double timeSeconds, double speed) {
const double phase = std::sin(timeSeconds * speed);
return static_cast<float>(0.5 + phase * 0.5);
}
UIRect InsetRect(const UIRect& rect, float insetX, float insetY) {
return UIRect(
rect.x + insetX,
rect.y + insetY,
(std::max)(0.0f, rect.width - insetX * 2.0f),
(std::max)(0.0f, rect.height - insetY * 2.0f));
}
void AddSectionTitle(
UIDrawList& drawList,
float x,
float y,
std::string_view title,
std::string_view subtitle = {}) {
drawList.AddText(UIPoint(x, y), std::string(title), kTextPrimary, 17.0f);
if (!subtitle.empty()) {
drawList.AddText(UIPoint(x, y + 20.0f), std::string(subtitle), kTextMuted, 13.0f);
}
}
void AddRow(
UIDrawList& drawList,
const UIRect& rect,
std::string_view label,
bool selected = false,
float indent = 0.0f,
const UIColor& dotColor = kAccentColor) {
if (selected) {
drawList.AddFilledRect(rect, kSelectionColor, 6.0f);
}
drawList.AddFilledRect(
UIRect(rect.x + 10.0f + indent, rect.y + 10.0f, 8.0f, 8.0f),
dotColor,
4.0f);
drawList.AddText(
UIPoint(rect.x + 26.0f + indent, rect.y + 6.0f),
std::string(label),
selected ? kTextPrimary : kTextSecondary,
14.0f);
}
void AddPropertyRow(
UIDrawList& drawList,
const UIRect& rect,
std::string_view name,
std::string_view value) {
drawList.AddText(UIPoint(rect.x, rect.y + 6.0f), std::string(name), kTextSecondary, 14.0f);
const UIRect fieldRect(rect.x + rect.width * 0.42f, rect.y, rect.width * 0.58f, rect.height);
drawList.AddFilledRect(fieldRect, UIColor(0.11f, 0.15f, 0.20f, 1.0f), 6.0f);
drawList.AddRectOutline(fieldRect, kSubtleLineColor, 1.0f, 6.0f);
drawList.AddText(
UIPoint(fieldRect.x + 10.0f, fieldRect.y + 6.0f),
std::string(value),
kTextPrimary,
14.0f);
}
void AddLogRow(
UIDrawList& drawList,
const UIRect& rect,
const UIColor& levelColor,
std::string_view level,
std::string_view message) {
drawList.AddFilledRect(
UIRect(rect.x, rect.y + 7.0f, 6.0f, rect.height - 14.0f),
levelColor,
3.0f);
drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 4.0f), std::string(level), levelColor, 13.0f);
drawList.AddText(UIPoint(rect.x + 82.0f, rect.y + 4.0f), std::string(message), kTextSecondary, 13.0f);
}
void AppendPanel(
UIDrawList& drawList,
const UIRect& panelRect,
std::string_view title,
std::string_view subtitle,
std::string_view footer,
bool active) {
UIEditorPanelChromeState state = {};
state.active = active;
state.hovered = active;
UIEditorPanelChromeText text = {};
text.title = title;
text.subtitle = subtitle;
text.footer = footer;
UIEditorPanelChromeMetrics metrics = {};
metrics.headerHeight = 46.0f;
AppendUIEditorPanelChromeBackground(drawList, panelRect, state, {}, metrics);
AppendUIEditorPanelChromeForeground(drawList, panelRect, text, {}, metrics);
}
void AppendTopBar(UIDrawList& drawList, const UIRect& rect, double timeSeconds) {
drawList.AddFilledRect(rect, kShellColor, 0.0f);
drawList.AddRectOutline(rect, kSubtleLineColor, 1.0f, 0.0f);
drawList.AddText(
UIPoint(rect.x + 20.0f, rect.y + 14.0f),
"XCUI Native Sandbox",
kTextPrimary,
20.0f);
drawList.AddText(
UIPoint(rect.x + 228.0f, rect.y + 17.0f),
"editor ui proving ground",
kTextMuted,
13.0f);
const float pulse = Pulse01(timeSeconds, 2.2);
const UIColor liveColor(
0.18f + pulse * 0.16f,
0.74f,
0.58f,
1.0f);
drawList.AddFilledRect(
UIRect(rect.x + rect.width - 170.0f, rect.y + 12.0f, 108.0f, 24.0f),
UIColor(0.10f, 0.16f, 0.12f, 1.0f),
12.0f);
drawList.AddFilledRect(
UIRect(rect.x + rect.width - 160.0f, rect.y + 20.0f, 8.0f, 8.0f),
liveColor,
4.0f);
drawList.AddText(
UIPoint(rect.x + rect.width - 146.0f, rect.y + 14.0f),
"Native Render",
kTextPrimary,
13.0f);
}
void AppendStatusBar(UIDrawList& drawList, const UIRect& rect, const SandboxFrameOptions& options) {
drawList.AddFilledRect(rect, kShellColor, 0.0f);
drawList.AddRectOutline(rect, kSubtleLineColor, 1.0f, 0.0f);
const std::string leftText =
"Direct renderer | size " +
std::to_string(static_cast<int>(options.width)) +
"x" +
std::to_string(static_cast<int>(options.height));
drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 8.0f), leftText, kTextSecondary, 13.0f);
drawList.AddText(
UIPoint(rect.x + rect.width - 176.0f, rect.y + 8.0f),
"No ImGui Host",
kAccentColor,
13.0f);
}
void AppendHierarchyPanel(UIDrawList& drawList, const UIRect& panelRect) {
AppendPanel(drawList, panelRect, "Hierarchy", "scene graph", "12 items", false);
const UIRect body = InsetRect(panelRect, 16.0f, 60.0f);
AddSectionTitle(drawList, body.x, body.y, "Active Scene", "Sandbox_City.xcscene");
float rowY = body.y + 42.0f;
const float rowHeight = 28.0f;
AddRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "World", false, 0.0f, kWarningColor);
rowY += rowHeight + 4.0f;
AddRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "Main Camera", false, 18.0f);
rowY += rowHeight + 4.0f;
AddRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "Directional Light", false, 18.0f, kWarningColor);
rowY += rowHeight + 4.0f;
AddRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "Player", true, 18.0f);
rowY += rowHeight + 4.0f;
AddRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "WeaponSocket", false, 36.0f, UIColor(0.58f, 0.70f, 0.88f, 1.0f));
rowY += rowHeight + 4.0f;
AddRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "FX_Trail", false, 36.0f, UIColor(0.76f, 0.43f, 0.93f, 1.0f));
}
void AppendInspectorPanel(UIDrawList& drawList, const UIRect& panelRect) {
AppendPanel(drawList, panelRect, "Inspector", "Player", "schema-first draft", false);
const UIRect body = InsetRect(panelRect, 16.0f, 60.0f);
AddSectionTitle(drawList, body.x, body.y, "Transform", "shared widget experiment");
float rowY = body.y + 42.0f;
const float rowHeight = 30.0f;
AddPropertyRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "Position", "12.0, 1.8, -4.0");
rowY += rowHeight + 6.0f;
AddPropertyRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "Rotation", "0.0, 36.5, 0.0");
rowY += rowHeight + 6.0f;
AddPropertyRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "Scale", "1.0, 1.0, 1.0");
rowY += 48.0f;
AddSectionTitle(drawList, body.x, rowY, "Character", "future AutoForm target");
rowY += 42.0f;
AddPropertyRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "State", "Locomotion");
rowY += rowHeight + 6.0f;
AddPropertyRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "Move Speed", "6.4");
rowY += rowHeight + 6.0f;
AddPropertyRow(drawList, UIRect(body.x, rowY, body.width, rowHeight), "Jump Height", "1.3");
}
void AppendScenePanel(UIDrawList& drawList, const UIRect& panelRect, double timeSeconds) {
AppendPanel(drawList, panelRect, "Scene", "native shell draft", "ViewportSlot comes later", true);
const UIRect body = InsetRect(panelRect, 16.0f, 60.0f);
drawList.AddFilledRect(body, UIColor(0.07f, 0.09f, 0.11f, 1.0f), 12.0f);
drawList.AddRectOutline(body, kSubtleLineColor, 1.0f, 12.0f);
const float gridStep = 34.0f;
for (float x = body.x; x < body.x + body.width; x += gridStep) {
drawList.AddRectOutline(UIRect(x, body.y, 1.0f, body.height), UIColor(0.10f, 0.13f, 0.16f, 0.6f));
}
for (float y = body.y; y < body.y + body.height; y += gridStep) {
drawList.AddRectOutline(UIRect(body.x, y, body.width, 1.0f), UIColor(0.10f, 0.13f, 0.16f, 0.6f));
}
const float pulse = Pulse01(timeSeconds, 1.8);
const UIRect selectionRect(
body.x + body.width * 0.32f,
body.y + body.height * 0.24f,
body.width * 0.22f,
body.height * 0.34f);
drawList.AddRectOutline(
selectionRect,
UIColor(0.24f + pulse * 0.12f, 0.56f, 0.95f, 1.0f),
2.0f,
8.0f);
drawList.AddText(
UIPoint(body.x + 18.0f, body.y + 18.0f),
"This area is intentionally native-rendered, not hosted by ImGui.",
kTextSecondary,
15.0f);
drawList.AddText(
UIPoint(body.x + 18.0f, body.y + 40.0f),
"Use this sandbox to iterate shell chrome, panel composition, and draw packets.",
kTextMuted,
13.0f);
}
void AppendConsolePanel(UIDrawList& drawList, const UIRect& panelRect) {
AppendPanel(drawList, panelRect, "Console", "native renderer smoke log", "6 records", false);
const UIRect body = InsetRect(panelRect, 16.0f, 60.0f);
AddLogRow(drawList, UIRect(body.x, body.y, body.width, 22.0f), kAccentColor, "Info", "XCUI native sandbox frame submitted.");
AddLogRow(drawList, UIRect(body.x, body.y + 26.0f, body.width, 22.0f), kWarningColor, "Warn", "Viewport host not connected in this phase.");
AddLogRow(drawList, UIRect(body.x, body.y + 52.0f, body.width, 22.0f), kTextSecondary, "Info", "Hierarchy / Inspector are draw-packet prototypes.");
AddLogRow(drawList, UIRect(body.x, body.y + 78.0f, body.width, 22.0f), kErrorColor, "Todo", "Replace bespoke scene panel with authored shell document.");
}
} // namespace
UIDrawData BuildSandboxFrame(const SandboxFrameOptions& options) {
const float width = ClampPositive(options.width, 1280.0f);
const float height = ClampPositive(options.height, 720.0f);
const float margin = 18.0f;
const float gap = 14.0f;
const float topBarHeight = 48.0f;
const float statusBarHeight = 30.0f;
const float leftPanelWidth = 260.0f;
const float rightPanelWidth = 320.0f;
const float bottomPanelHeight = 210.0f;
const UIRect fullRect(0.0f, 0.0f, width, height);
const UIRect topBarRect(0.0f, 0.0f, width, topBarHeight);
const UIRect statusRect(0.0f, height - statusBarHeight, width, statusBarHeight);
const float workspaceTop = topBarHeight + margin;
const float workspaceBottom = height - statusBarHeight - margin;
const float workspaceHeight = (std::max)(0.0f, workspaceBottom - workspaceTop);
const float centerX = margin + leftPanelWidth + gap;
const float centerWidth = (std::max)(240.0f, width - margin * 2.0f - leftPanelWidth - rightPanelWidth - gap * 2.0f);
const float sceneHeight = (std::max)(220.0f, workspaceHeight - bottomPanelHeight - gap);
const UIRect hierarchyRect(margin, workspaceTop, leftPanelWidth, workspaceHeight);
const UIRect sceneRect(centerX, workspaceTop, centerWidth, sceneHeight);
const UIRect consoleRect(centerX, workspaceTop + sceneHeight + gap, centerWidth, bottomPanelHeight);
const UIRect inspectorRect(centerX + centerWidth + gap, workspaceTop, rightPanelWidth, workspaceHeight);
UIDrawData drawData = {};
UIDrawList& drawList = drawData.EmplaceDrawList("XCUI Native Editor Sandbox");
drawList.PushClipRect(fullRect);
drawList.AddFilledRect(fullRect, kWorkspaceColor, 0.0f);
AppendTopBar(drawList, topBarRect, options.timeSeconds);
AppendHierarchyPanel(drawList, hierarchyRect);
AppendScenePanel(drawList, sceneRect, options.timeSeconds);
AppendConsolePanel(drawList, consoleRect);
AppendInspectorPanel(drawList, inspectorRect);
AppendStatusBar(drawList, statusRect, options);
drawList.PopClipRect();
return drawData;
}
} // namespace NewEditor
} // namespace XCEngine

View File

@@ -1,17 +0,0 @@
#pragma once
#include <XCEngine/UI/DrawData.h>
namespace XCEngine {
namespace NewEditor {
struct SandboxFrameOptions {
float width = 1280.0f;
float height = 720.0f;
double timeSeconds = 0.0;
};
::XCEngine::UI::UIDrawData BuildSandboxFrame(const SandboxFrameOptions& options);
} // namespace NewEditor
} // namespace XCEngine

View File

@@ -1,4 +1,4 @@
#include <XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h> #include <XCNewEditor/Widgets/UIEditorCollectionPrimitives.h>
namespace XCEngine { namespace XCEngine {
namespace UI { namespace UI {

View File

@@ -0,0 +1,13 @@
#include "EditorShellAsset.h"
namespace XCEngine::NewEditor {
EditorShellAsset BuildDefaultEditorShellAsset(const std::filesystem::path& repoRoot) {
EditorShellAsset asset = {};
asset.documentPath = (repoRoot / "new_editor/ui/views/editor_shell.xcui").lexically_normal();
asset.themePath = (repoRoot / "new_editor/ui/themes/editor_shell.xctheme").lexically_normal();
asset.captureRootPath = (repoRoot / "new_editor/captures").lexically_normal();
return asset;
}
} // namespace XCEngine::NewEditor

View File

@@ -0,0 +1,17 @@
#pragma once
#include <filesystem>
#include <string>
namespace XCEngine::NewEditor {
struct EditorShellAsset {
std::string screenId = "new_editor.shell";
std::filesystem::path documentPath = {};
std::filesystem::path themePath = {};
std::filesystem::path captureRootPath = {};
};
EditorShellAsset BuildDefaultEditorShellAsset(const std::filesystem::path& repoRoot);
} // namespace XCEngine::NewEditor

View File

@@ -1,4 +1,4 @@
#include "Application.h" #include "Host/Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::NewEditor::RunNewEditor(hInstance, nCmdShow); return XCEngine::NewEditor::RunNewEditor(hInstance, nCmdShow);

View File

@@ -1,21 +0,0 @@
<Schema name="EditorInspectorShell">
<Element tag="InspectorPanel" allowUnknownAttributes="true">
<Attribute name="vm" type="string" />
<Attribute name="schema" type="document" documentKind="schema" />
<Element tag="Section" allowUnknownAttributes="true">
<Attribute name="title" type="string" />
<Attribute name="subtitle" type="string" />
<Element tag="Field" allowUnknownAttributes="true">
<Attribute name="label" type="string" />
<Attribute name="binding" type="string" />
<Attribute
name="widget"
type="enum"
values="Vector3Field,FloatField,EnumField,AssetField" />
<Attribute name="step" type="number" />
</Element>
</Element>
</Element>
</Schema>

View File

@@ -1,30 +1,8 @@
<View <View
name="NewEditorShell" name="NewEditorShell"
theme="../themes/editor_shell.xctheme"> theme="../themes/editor_shell.xctheme">
<Column padding="24" gap="16"> <Column
<Card id="editor-shell-root"
title="XCUI Core Validation" width="fill"
subtitle="Current batch: input state routing | minimal sandbox | native renderer" height="fill" />
tone="accent"
height="90">
<Column gap="8">
<Text text="这个试验面板只保留当前批次需要检查的控件。" />
<Text text="无关的 editor 壳层面板暂时不放进来,避免干扰检查。" />
</Column>
</Card>
<Card title="Input Core" subtitle="hover focus active capture" height="196">
<Column gap="12">
<Text text="这一轮只需要检查下面这三个按钮。" />
<Row gap="12">
<Button id="input-hover" text="Hover / Focus" />
<Button id="input-capture" text="Pointer Capture" capturePointer="true" />
<Button id="input-route" text="Route Target" />
</Row>
<Text text="1. 鼠标移到左侧按钮hover 应该变化focus 应该保持空。" />
<Text text="2. 按住中间按钮不放focus、active、capture 都应该停在中间按钮。" />
<Text text="3. 按住中间按钮拖到右侧再松开hover 移到右侧capture 清空focus 仍留在中间。" />
</Column>
</Card>
</Column>
</View> </View>

View File

@@ -32,6 +32,7 @@ enable_testing()
# ============================================================ # ============================================================
add_subdirectory(Core) add_subdirectory(Core)
add_subdirectory(UI)
add_subdirectory(Memory) add_subdirectory(Memory)
add_subdirectory(Threading) add_subdirectory(Threading)
add_subdirectory(Debug) add_subdirectory(Debug)
@@ -43,9 +44,6 @@ add_subdirectory(RHI)
add_subdirectory(Resources) add_subdirectory(Resources)
add_subdirectory(Input) add_subdirectory(Input)
add_subdirectory(Editor) add_subdirectory(Editor)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/NewEditor")
add_subdirectory(NewEditor)
endif()
if(WIN32) if(WIN32)
find_program(XCENGINE_POWERSHELL_EXECUTABLE NAMES powershell pwsh REQUIRED) find_program(XCENGINE_POWERSHELL_EXECUTABLE NAMES powershell pwsh REQUIRED)

View File

@@ -4,8 +4,6 @@
set(UI_TEST_SOURCES set(UI_TEST_SOURCES
test_ui_core.cpp test_ui_core.cpp
test_ui_editor_collection_primitives.cpp
test_ui_editor_panel_chrome.cpp
test_ui_expansion_model.cpp test_ui_expansion_model.cpp
test_ui_flat_hierarchy_helpers.cpp test_ui_flat_hierarchy_helpers.cpp
test_ui_input_dispatcher.cpp test_ui_input_dispatcher.cpp

7
tests/UI/CMakeLists.txt Normal file
View File

@@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.15)
project(XCEngine_UITests)
add_subdirectory(Core)
add_subdirectory(Runtime)
add_subdirectory(Editor)

View File

@@ -0,0 +1,17 @@
cmake_minimum_required(VERSION 3.15)
project(XCEngine_CoreUITests)
add_subdirectory(unit)
add_subdirectory(integration)
add_custom_target(core_ui_unit_tests
DEPENDS
core_ui_tests
)
add_custom_target(core_ui_all_tests
DEPENDS
core_ui_unit_tests
core_ui_integration_tests
)

View File

@@ -0,0 +1 @@
add_custom_target(core_ui_integration_tests)

View File

@@ -0,0 +1,8 @@
# Core UI Integration Notes
The core XCUI lane currently validates shared primitives through automated unit tests.
Interactive validation belongs to:
- `tests/UI/Runtime/integration/` for game/runtime UI
- `tests/UI/Editor/integration/` for editor UI

View File

@@ -0,0 +1,40 @@
set(CORE_UI_TEST_SOURCES
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_shortcut_scope.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_interaction.cpp
# Migration bridge: legacy XCUI unit coverage still lives under tests/Core/UI
# until it is moved into tests/UI/Core/unit without changing behavior.
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_core.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_expansion_model.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_flat_hierarchy_helpers.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_input_dispatcher.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_keyboard_navigation_model.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_property_edit_model.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_layout_engine.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_selection_model.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_text_editing.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_text_input_controller.cpp
)
add_executable(core_ui_tests ${CORE_UI_TEST_SOURCES})
if(MSVC)
set_target_properties(core_ui_tests PROPERTIES
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
)
endif()
target_link_libraries(core_ui_tests
PRIVATE
XCEngine
GTest::gtest
GTest::gtest_main
)
target_include_directories(core_ui_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/tests/Fixtures
)
include(GoogleTest)
gtest_discover_tests(core_ui_tests)

View File

@@ -0,0 +1,171 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Input/UIInputDispatcher.h>
#include <XCEngine/UI/Input/UIShortcutRegistry.h>
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIInputDispatchDecision;
using XCEngine::UI::UIInputDispatcher;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIInputPath;
using XCEngine::UI::UIShortcutBinding;
using XCEngine::UI::UIShortcutContext;
using XCEngine::UI::UIShortcutRegistry;
using XCEngine::UI::UIShortcutScope;
UIInputEvent MakeCtrlPEvent() {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(KeyCode::P);
event.modifiers.control = true;
return event;
}
} // namespace
TEST(UIShortcutScopeTest, RegistryUsesCommandScopeInsteadOfActivePathForShortcutResolution) {
UIShortcutRegistry registry = {};
UIShortcutBinding dragWidgetBinding = {};
dragWidgetBinding.scope = UIShortcutScope::Widget;
dragWidgetBinding.ownerId = 91u;
dragWidgetBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
dragWidgetBinding.chord.modifiers.control = true;
dragWidgetBinding.commandId = "drag.widget.command";
registry.RegisterBinding(dragWidgetBinding);
UIShortcutBinding panelBinding = {};
panelBinding.scope = UIShortcutScope::Panel;
panelBinding.ownerId = 20u;
panelBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
panelBinding.chord.modifiers.control = true;
panelBinding.commandId = "panel.command";
registry.RegisterBinding(panelBinding);
UIShortcutContext context = {};
context.focusedPath = { 10u, 20u, 30u };
context.activePath = { 90u, 91u };
context.commandScope.path = context.focusedPath;
context.commandScope.windowId = 10u;
context.commandScope.panelId = 20u;
context.commandScope.widgetId = 30u;
const auto match = registry.Match(MakeCtrlPEvent(), context);
ASSERT_TRUE(match.matched);
EXPECT_EQ(match.binding.commandId, "panel.command");
}
TEST(UIShortcutScopeTest, RegistryPrefersPanelThenWindowThenGlobalWithinFocusedCommandScope) {
UIShortcutRegistry registry = {};
UIShortcutBinding globalBinding = {};
globalBinding.scope = UIShortcutScope::Global;
globalBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
globalBinding.chord.modifiers.control = true;
globalBinding.commandId = "global.command";
registry.RegisterBinding(globalBinding);
UIShortcutBinding windowBinding = {};
windowBinding.scope = UIShortcutScope::Window;
windowBinding.ownerId = 10u;
windowBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
windowBinding.chord.modifiers.control = true;
windowBinding.commandId = "window.command";
registry.RegisterBinding(windowBinding);
UIShortcutBinding panelBinding = {};
panelBinding.scope = UIShortcutScope::Panel;
panelBinding.ownerId = 20u;
panelBinding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
panelBinding.chord.modifiers.control = true;
panelBinding.commandId = "panel.command";
registry.RegisterBinding(panelBinding);
UIShortcutContext context = {};
context.focusedPath = { 10u, 20u, 30u };
context.commandScope.path = context.focusedPath;
context.commandScope.windowId = 10u;
context.commandScope.panelId = 20u;
context.commandScope.widgetId = 30u;
const auto match = registry.Match(MakeCtrlPEvent(), context);
ASSERT_TRUE(match.matched);
EXPECT_EQ(match.binding.commandId, "panel.command");
}
TEST(UIShortcutScopeTest, InputDispatcherConsumesShortcutBeforeRoutingWhenCommandScopeMatches) {
UIInputDispatcher dispatcher{};
dispatcher.GetFocusController().SetFocusedPath({ 10u, 20u, 30u });
UIShortcutContext shortcutContext = {};
shortcutContext.commandScope.path = { 10u, 20u, 30u };
shortcutContext.commandScope.windowId = 10u;
shortcutContext.commandScope.panelId = 20u;
shortcutContext.commandScope.widgetId = 30u;
dispatcher.SetShortcutContext(shortcutContext);
UIShortcutBinding binding = {};
binding.scope = UIShortcutScope::Panel;
binding.ownerId = 20u;
binding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
binding.chord.modifiers.control = true;
binding.commandId = "panel.command";
dispatcher.GetShortcutRegistry().RegisterBinding(binding);
bool handlerCalled = false;
const auto summary = dispatcher.Dispatch(
MakeCtrlPEvent(),
{},
[&handlerCalled](const auto&) {
handlerCalled = true;
return UIInputDispatchDecision{};
});
EXPECT_TRUE(summary.shortcutMatched);
EXPECT_TRUE(summary.shortcutHandled);
EXPECT_FALSE(summary.shortcutSuppressed);
EXPECT_EQ(summary.commandId, "panel.command");
EXPECT_EQ(summary.shortcutScope, UIShortcutScope::Panel);
EXPECT_EQ(summary.shortcutOwnerId, 20u);
EXPECT_FALSE(handlerCalled);
}
TEST(UIShortcutScopeTest, InputDispatcherSuppressesShortcutWhileTextInputIsActiveButStillRoutesEvent) {
UIInputDispatcher dispatcher{};
dispatcher.GetFocusController().SetFocusedPath({ 10u, 20u, 30u });
UIShortcutContext shortcutContext = {};
shortcutContext.commandScope.path = { 10u, 20u, 30u };
shortcutContext.commandScope.windowId = 10u;
shortcutContext.commandScope.panelId = 20u;
shortcutContext.commandScope.widgetId = 30u;
shortcutContext.textInputActive = true;
dispatcher.SetShortcutContext(shortcutContext);
UIShortcutBinding binding = {};
binding.scope = UIShortcutScope::Panel;
binding.ownerId = 20u;
binding.chord.keyCode = static_cast<std::int32_t>(KeyCode::P);
binding.chord.modifiers.control = true;
binding.commandId = "panel.command";
dispatcher.GetShortcutRegistry().RegisterBinding(binding);
bool handlerCalled = false;
const auto summary = dispatcher.Dispatch(
MakeCtrlPEvent(),
{},
[&handlerCalled](const auto&) {
handlerCalled = true;
return UIInputDispatchDecision{};
});
EXPECT_TRUE(summary.shortcutMatched);
EXPECT_FALSE(summary.shortcutHandled);
EXPECT_TRUE(summary.shortcutSuppressed);
EXPECT_EQ(summary.commandId, "panel.command");
EXPECT_TRUE(handlerCalled);
}

View File

@@ -0,0 +1,112 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Layout/UISplitterLayout.h>
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
namespace {
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::Layout::UILayoutAxis;
using XCEngine::UI::Layout::ArrangeUISplitter;
using XCEngine::UI::Layout::UISplitterConstraints;
using XCEngine::UI::Layout::UISplitterLayoutResult;
using XCEngine::UI::Layout::UISplitterMetrics;
using XCEngine::UI::Widgets::BeginUISplitterDrag;
using XCEngine::UI::Widgets::EndUISplitterDrag;
using XCEngine::UI::Widgets::ExpandUISplitterHandleHitRect;
using XCEngine::UI::Widgets::HitTestUISplitterHandle;
using XCEngine::UI::Widgets::UISplitterDragState;
using XCEngine::UI::Widgets::UpdateUISplitterDrag;
void ExpectRect(
const UIRect& rect,
float x,
float y,
float width,
float height) {
EXPECT_FLOAT_EQ(rect.x, x);
EXPECT_FLOAT_EQ(rect.y, y);
EXPECT_FLOAT_EQ(rect.width, width);
EXPECT_FLOAT_EQ(rect.height, height);
}
} // namespace
TEST(UISplitterInteractionTest, HorizontalSplitterHitRectExpandsAcrossDividerWidth) {
const UIRect hitRect = ExpandUISplitterHandleHitRect(
UIRect(240.0f, 24.0f, 8.0f, 260.0f),
UILayoutAxis::Horizontal,
4.0f);
ExpectRect(hitRect, 236.0f, 24.0f, 16.0f, 260.0f);
}
TEST(UISplitterInteractionTest, VerticalSplitterHitRectExpandsAcrossDividerHeight) {
const UIRect hitRect = ExpandUISplitterHandleHitRect(
UIRect(16.0f, 180.0f, 420.0f, 10.0f),
UILayoutAxis::Vertical,
5.0f);
ExpectRect(hitRect, 16.0f, 175.0f, 420.0f, 20.0f);
}
TEST(UISplitterInteractionTest, HitTestUsesExpandedHorizontalHandleRect) {
EXPECT_TRUE(HitTestUISplitterHandle(
UIRect(240.0f, 24.0f, 8.0f, 260.0f),
UILayoutAxis::Horizontal,
UIPoint(236.5f, 80.0f),
4.0f));
EXPECT_FALSE(HitTestUISplitterHandle(
UIRect(240.0f, 24.0f, 8.0f, 260.0f),
UILayoutAxis::Horizontal,
UIPoint(235.0f, 80.0f),
4.0f));
}
TEST(UISplitterInteractionTest, HitTestUsesExpandedVerticalHandleRect) {
EXPECT_TRUE(HitTestUISplitterHandle(
UIRect(16.0f, 180.0f, 420.0f, 10.0f),
UILayoutAxis::Vertical,
UIPoint(90.0f, 176.0f),
5.0f));
EXPECT_FALSE(HitTestUISplitterHandle(
UIRect(16.0f, 180.0f, 420.0f, 10.0f),
UILayoutAxis::Vertical,
UIPoint(90.0f, 174.0f),
5.0f));
}
TEST(UISplitterInteractionTest, DragUpdateClampsPrimaryExtentAgainstConstraints) {
UISplitterConstraints constraints = {};
constraints.primaryMin = 120.0f;
constraints.secondaryMin = 180.0f;
const UISplitterMetrics metrics = { 10.0f, 18.0f };
const UIRect bounds(0.0f, 0.0f, 500.0f, 180.0f);
const UISplitterLayoutResult initialLayout = ArrangeUISplitter(
bounds,
UILayoutAxis::Horizontal,
0.5f,
constraints,
metrics);
UISplitterDragState dragState = {};
ASSERT_TRUE(BeginUISplitterDrag(
42u,
UILayoutAxis::Horizontal,
bounds,
initialLayout,
constraints,
metrics,
UIPoint(initialLayout.handleRect.x + 5.0f, 90.0f),
dragState));
UISplitterLayoutResult updatedLayout = {};
ASSERT_TRUE(UpdateUISplitterDrag(dragState, UIPoint(80.0f, 90.0f), updatedLayout));
EXPECT_FLOAT_EQ(updatedLayout.primaryExtent, 120.0f);
EXPECT_FLOAT_EQ(updatedLayout.secondaryExtent, 370.0f);
EndUISplitterDrag(dragState);
EXPECT_FALSE(dragState.active);
EXPECT_EQ(dragState.ownerId, 0u);
}

View File

@@ -0,0 +1,76 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Layout/UISplitterLayout.h>
namespace {
using XCEngine::UI::UIRect;
using XCEngine::UI::Layout::ArrangeUISplitter;
using XCEngine::UI::Layout::UILayoutAxis;
using XCEngine::UI::Layout::UISplitterConstraints;
using XCEngine::UI::Layout::UISplitterMetrics;
void ExpectRect(
const UIRect& rect,
float x,
float y,
float width,
float height) {
EXPECT_FLOAT_EQ(rect.x, x);
EXPECT_FLOAT_EQ(rect.y, y);
EXPECT_FLOAT_EQ(rect.width, width);
EXPECT_FLOAT_EQ(rect.height, height);
}
} // namespace
TEST(UISplitterLayoutTest, ArrangeHorizontalSplitterClampsPrimaryExtentToMinimum) {
UISplitterConstraints constraints = {};
constraints.primaryMin = 200.0f;
constraints.secondaryMin = 120.0f;
const auto result = ArrangeUISplitter(
UIRect(0.0f, 0.0f, 600.0f, 300.0f),
UILayoutAxis::Horizontal,
0.1f,
constraints,
UISplitterMetrics{ 10.0f, 18.0f });
ExpectRect(result.primaryRect, 0.0f, 0.0f, 200.0f, 300.0f);
ExpectRect(result.handleRect, 200.0f, 0.0f, 10.0f, 300.0f);
ExpectRect(result.secondaryRect, 210.0f, 0.0f, 390.0f, 300.0f);
EXPECT_NEAR(result.splitRatio, 200.0f / 590.0f, 0.0001f);
}
TEST(UISplitterLayoutTest, ArrangeVerticalSplitterClampsSecondaryMinimumAgainstRequestedRatio) {
UISplitterConstraints constraints = {};
constraints.primaryMin = 100.0f;
constraints.secondaryMin = 120.0f;
const auto result = ArrangeUISplitter(
UIRect(0.0f, 0.0f, 500.0f, 400.0f),
UILayoutAxis::Vertical,
0.9f,
constraints,
UISplitterMetrics{ 8.0f, 16.0f });
ExpectRect(result.primaryRect, 0.0f, 0.0f, 500.0f, 272.0f);
ExpectRect(result.handleRect, 0.0f, 272.0f, 500.0f, 8.0f);
ExpectRect(result.secondaryRect, 0.0f, 280.0f, 500.0f, 120.0f);
EXPECT_NEAR(result.splitRatio, 272.0f / 392.0f, 0.0001f);
}
TEST(UISplitterLayoutTest, HorizontalArrangementSplitsAvailableExtentAroundHandle) {
const auto result = ArrangeUISplitter(
UIRect(10.0f, 20.0f, 400.0f, 120.0f),
UILayoutAxis::Horizontal,
0.5f,
{},
UISplitterMetrics{ 8.0f, 14.0f });
EXPECT_FLOAT_EQ(result.primaryExtent, 196.0f);
EXPECT_FLOAT_EQ(result.secondaryExtent, 196.0f);
ExpectRect(result.primaryRect, 10.0f, 20.0f, 196.0f, 120.0f);
ExpectRect(result.handleRect, 206.0f, 20.0f, 8.0f, 120.0f);
ExpectRect(result.secondaryRect, 214.0f, 20.0f, 196.0f, 120.0f);
}

View File

@@ -0,0 +1,18 @@
cmake_minimum_required(VERSION 3.15)
project(XCEngine_EditorUITests)
add_subdirectory(integration/shared)
add_subdirectory(unit)
add_subdirectory(integration)
add_custom_target(editor_ui_unit_tests
DEPENDS
editor_ui_tests
)
add_custom_target(editor_ui_all_tests
DEPENDS
editor_ui_unit_tests
editor_ui_integration_tests
)

View File

@@ -0,0 +1,8 @@
add_subdirectory(input)
add_subdirectory(layout)
add_custom_target(editor_ui_integration_tests
DEPENDS
editor_ui_input_integration_tests
editor_ui_layout_integration_tests
)

View File

@@ -0,0 +1,22 @@
# Editor UI Integration Validation
This directory contains the manual XCUI validation system for editor-facing scenarios.
Structure:
- `shared/`: shared host, native renderer, screenshot helper, scenario registry
- `input/`: input-related validation category
- `layout/`: layout and shell-foundation validation category
Rules:
- One scenario directory maps to one executable.
- Do not accumulate unrelated checks into one monolithic app.
- Shared infrastructure belongs in `shared/`, not duplicated per scenario.
- Screenshots are stored per scenario inside that scenario's `captures/` folder.
Build:
```bash
cmake --build build --config Debug --target editor_ui_integration_tests
```

View File

@@ -0,0 +1,10 @@
add_subdirectory(keyboard_focus)
add_subdirectory(pointer_states)
add_subdirectory(shortcut_scope)
add_custom_target(editor_ui_input_integration_tests
DEPENDS
editor_ui_input_keyboard_focus_validation
editor_ui_input_pointer_states_validation
editor_ui_input_shortcut_scope_validation
)

View File

@@ -0,0 +1,9 @@
# Editor Input Integration
这个分类只放 editor 输入相关的手工验证场景。
规则:
- 一个场景目录对应一个独立 exe
- 共享宿主层只放在 `integration/shared/`
- 不允许把多个无关检查点塞进同一个 exe

View File

@@ -0,0 +1,35 @@
set(EDITOR_UI_INPUT_KEYBOARD_FOCUS_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_input_keyboard_focus_validation WIN32
main.cpp
${EDITOR_UI_INPUT_KEYBOARD_FOCUS_RESOURCES}
)
target_include_directories(editor_ui_input_keyboard_focus_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_input_keyboard_focus_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_input_keyboard_focus_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_input_keyboard_focus_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_input_keyboard_focus_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_input_keyboard_focus_validation PROPERTIES
OUTPUT_NAME "XCUIEditorInputKeyboardFocusValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,18 @@
# Keyboard Focus Validation
可执行 target
- `editor_ui_input_keyboard_focus_validation`
运行:
```bash
build\tests\UI\Editor\integration\input\keyboard_focus\Debug\XCUIEditorInputKeyboardFocusValidation.exe
```
检查点:
1.`Tab`,焦点依次切换三个按钮
2.`Shift+Tab`,焦点反向切换
3.`Enter``Space`,当前 `focus` 按钮进入 `active`
4. 松开按键后,`active` 清空

View File

@@ -0,0 +1,30 @@
<View
name="EditorInputKeyboardFocus"
theme="../../shared/themes/editor_validation.xctheme">
<Column padding="24" gap="16">
<Card
title="Editor Validation | Keyboard Focus"
subtitle="当前批次Tab 焦点遍历 | Enter / Space 激活"
tone="accent"
height="90">
<Column gap="8">
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查键盘焦点和激活,不混入复杂 editor 面板。" />
</Column>
</Card>
<Card title="Keyboard Focus" subtitle="tab focus active" height="214">
<Column gap="12">
<Text text="只检查下面三个可聚焦按钮和右下角状态叠层。" />
<Row gap="12">
<Button id="focus-first" text="First Focus" />
<Button id="focus-second" text="Second Focus" />
<Button id="focus-third" text="Third Focus" />
</Row>
<Text text="1. 按 Tabfocus 应依次切到 First / Second / Third。" />
<Text text="2. 按 Shift+Tabfocus 应反向切换。" />
<Text text="3. focus 停在任一按钮后,按 Enter 或 Spaceactive 应出现;松开后 active 清空。" />
</Column>
</Card>
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.input.keyboard_focus");
}

View File

@@ -0,0 +1,35 @@
set(EDITOR_UI_INPUT_POINTER_STATES_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_input_pointer_states_validation WIN32
main.cpp
${EDITOR_UI_INPUT_POINTER_STATES_RESOURCES}
)
target_include_directories(editor_ui_input_pointer_states_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_input_pointer_states_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_input_pointer_states_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_input_pointer_states_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_input_pointer_states_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_input_pointer_states_validation PROPERTIES
OUTPUT_NAME "XCUIEditorInputPointerStatesValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,17 @@
# Pointer States Validation
可执行 target
- `editor_ui_input_pointer_states_validation`
运行:
```bash
build\tests\UI\Editor\integration\input\pointer_states\Debug\XCUIEditorInputPointerStatesValidation.exe
```
检查点:
1. hover 左侧按钮,只应变化 `hover`
2. 按住中间按钮,应看到 `focus``active``capture`
3. 拖到右侧再松开,应看到 `capture` 清空route 转到新的目标

View File

@@ -0,0 +1,30 @@
<View
name="EditorInputPointerStates"
theme="../../shared/themes/editor_validation.xctheme">
<Column padding="24" gap="16">
<Card
title="Editor Validation | Pointer States"
subtitle="当前批次:鼠标 hover / focus / active / capture"
tone="accent"
height="90">
<Column gap="8">
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查鼠标输入状态,不混入别的控件实验。" />
</Column>
</Card>
<Card title="Pointer Input" subtitle="hover focus active capture" height="196">
<Column gap="12">
<Text text="这一轮只需要检查下面这三个按钮。" />
<Row gap="12">
<Button id="input-hover" text="Hover / Focus" />
<Button id="input-capture" text="Pointer Capture" capturePointer="true" />
<Button id="input-route" text="Route Target" />
</Row>
<Text text="1. 鼠标移到左侧按钮hover 应变化focus 保持空。" />
<Text text="2. 按住中间按钮focus、active、capture 都应留在中间。" />
<Text text="3. 拖到右侧再松开hover 移到右侧capture 清空focus 仍留中间。" />
</Column>
</Card>
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.input.pointer_states");
}

View File

@@ -0,0 +1,35 @@
set(EDITOR_UI_INPUT_SHORTCUT_SCOPE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_input_shortcut_scope_validation WIN32
main.cpp
${EDITOR_UI_INPUT_SHORTCUT_SCOPE_RESOURCES}
)
target_include_directories(editor_ui_input_shortcut_scope_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_input_shortcut_scope_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_input_shortcut_scope_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_input_shortcut_scope_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_input_shortcut_scope_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_input_shortcut_scope_validation PROPERTIES
OUTPUT_NAME "XCUIEditorInputShortcutScopeValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,69 @@
<View
name="EditorInputShortcutScope"
theme="../../shared/themes/editor_validation.xctheme"
shortcut="Ctrl+P"
shortcutCommand="global.command"
shortcutScope="global">
<Column padding="20" gap="12">
<Card
title="Editor Validation | Shortcut Scope"
subtitle="验证功能Editor shortcut scope 路由与 text input suppression"
tone="accent"
height="100">
<Column gap="6">
<Text text="功能 1验证 Ctrl+P 在 Widget / Panel / Window / Global 间按优先级命中 shortcut。" />
<Text text="功能 2验证 Text Input Proxy 会抑制 Ctrl+P 和 Tab 焦点遍历。" />
</Column>
</Card>
<Button id="global-focus" text="Global Focus" />
<Card
id="window-shell"
title="Window Scope"
subtitle="Ctrl+P -> window.command"
shortcutScopeRoot="window"
shortcut="Ctrl+P"
shortcutCommand="window.command"
shortcutScope="window">
<Column gap="10">
<Text text="先检查优先级widget > panel > window > global。" />
<Button id="window-focus" text="Window Focus" />
<Card
id="panel-shell"
title="Panel Scope"
subtitle="Ctrl+P -> panel.command"
shortcutScopeRoot="panel"
shortcut="Ctrl+P"
shortcutCommand="panel.command"
shortcutScope="panel">
<Column gap="10">
<Button id="panel-focus" text="Panel Focus" />
<Card
id="widget-shell"
title="Widget Scope"
subtitle="Ctrl+P -> widget.command"
tone="accent-alt"
shortcutScopeRoot="widget"
shortcut="Ctrl+P"
shortcutCommand="widget.command"
shortcutScope="widget">
<Column gap="10">
<Button id="widget-focus" text="Widget Focus" />
</Column>
</Card>
<Button id="text-input" text="Text Input Proxy" textInput="true" />
<Text text="操作指引:" />
<Text text="1. 依次点 Widget / Panel / Window / Global Focus再按 Ctrl+P。" />
<Text text="2. 右下角 Recent shortcut 应分别显示 widget / panel / window / global且状态为 handled。" />
<Text text="3. 点 Text Input Proxy 再按 Ctrl+PRecent shortcut 状态应变为 suppressed。" />
<Text text="4. 保持 Text Input Proxy focus 再按 TabResult 应显示 focus traversal suppressedfocus 不应跳走。" />
</Column>
</Card>
</Column>
</Card>
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.input.shortcut_scope");
}

View File

@@ -0,0 +1,6 @@
add_subdirectory(splitter_resize)
add_custom_target(editor_ui_layout_integration_tests
DEPENDS
editor_ui_layout_splitter_resize_validation
)

View File

@@ -0,0 +1,35 @@
set(EDITOR_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES
View.xcui
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme
)
add_executable(editor_ui_layout_splitter_resize_validation WIN32
main.cpp
${EDITOR_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES}
)
target_include_directories(editor_ui_layout_splitter_resize_validation PRIVATE
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_layout_splitter_resize_validation PRIVATE
UNICODE
_UNICODE
)
if(MSVC)
target_compile_options(editor_ui_layout_splitter_resize_validation PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_layout_splitter_resize_validation PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_layout_splitter_resize_validation PRIVATE
editor_ui_integration_host
)
set_target_properties(editor_ui_layout_splitter_resize_validation PROPERTIES
OUTPUT_NAME "XCUIEditorLayoutSplitterResizeValidation"
)
source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)

View File

@@ -0,0 +1,39 @@
<View
name="EditorSplitterResizeValidation"
theme="../../shared/themes/editor_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能Splitter / pane resize"
subtitle="这一轮只检查分割条拖拽和最小尺寸 clamp"
tone="accent"
height="128">
<Column gap="6">
<Text text="1. 鼠标移到中间 divider右下角 Hover 应落到 workspace-splitter。" />
<Text text="2. 按住左键拖拽:左右 pane 宽度应实时变化Result 应出现 Splitter drag started / Splitter resized。" />
<Text text="3. 向左右极限拖拽:布局应被 primaryMin / secondaryMin clamp 住,不应穿透。" />
<Text text="4. 松开左键Result 应显示 Splitter drag finished。" />
</Column>
</Card>
<Splitter
id="workspace-splitter"
axis="horizontal"
splitRatio="0.38"
splitterSize="10"
splitterHitSize="18"
primaryMin="180"
secondaryMin="220"
height="fill">
<Card id="left-pane" title="Left Empty Pane" subtitle="min 180" height="fill">
<Column gap="8">
<Text text="这里只保留空 pane用来观察 resize。" />
</Column>
</Card>
<Card id="right-pane" title="Right Empty Pane" subtitle="min 220" height="fill">
<Column gap="8">
<Text text="拖拽过程中不应出现翻转、穿透或抖动。" />
</Column>
</Card>
</Splitter>
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
hInstance,
nCmdShow,
"editor.layout.splitter_resize");
}

View File

@@ -0,0 +1,57 @@
file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH)
add_library(editor_ui_validation_registry STATIC
src/EditorValidationScenario.cpp
)
target_include_directories(editor_ui_validation_registry
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_validation_registry
PUBLIC
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(editor_ui_validation_registry PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_validation_registry PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_validation_registry
PUBLIC
XCEngine
)
add_library(editor_ui_integration_host STATIC
src/Application.cpp
)
target_include_directories(editor_ui_integration_host
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/new_editor/include
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(editor_ui_integration_host
PUBLIC
UNICODE
_UNICODE
XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(editor_ui_integration_host PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_integration_host PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(editor_ui_integration_host
PUBLIC
editor_ui_validation_registry
XCNewEditorHost
)

View File

@@ -0,0 +1,782 @@
#include "Application.h"
#include <XCEngine/Input/InputTypes.h>
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <sstream>
#include <string>
#include <system_error>
#include <unordered_set>
#include <vector>
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
#endif
namespace XCEngine::Tests::EditorUI {
namespace {
using ::XCEngine::UI::UIColor;
using ::XCEngine::UI::UIDrawData;
using ::XCEngine::UI::UIDrawList;
using ::XCEngine::UI::UIInputEvent;
using ::XCEngine::UI::UIInputEventType;
using ::XCEngine::UI::UIPoint;
using ::XCEngine::UI::UIPointerButton;
using ::XCEngine::UI::UIRect;
using ::XCEngine::UI::Runtime::UIScreenFrameInput;
using ::XCEngine::Input::KeyCode;
constexpr const wchar_t* kWindowClassName = L"XCUIEditorValidationHost";
constexpr const wchar_t* kWindowTitle = L"XCUI Editor Validation";
constexpr auto kReloadPollInterval = std::chrono::milliseconds(150);
constexpr UIColor kOverlayBgColor(0.10f, 0.10f, 0.10f, 0.95f);
constexpr UIColor kOverlayBorderColor(0.25f, 0.25f, 0.25f, 1.0f);
constexpr UIColor kOverlayTextPrimary(0.93f, 0.93f, 0.93f, 1.0f);
constexpr UIColor kOverlayTextMuted(0.70f, 0.70f, 0.70f, 1.0f);
constexpr UIColor kOverlaySuccess(0.82f, 0.82f, 0.82f, 1.0f);
constexpr UIColor kOverlayFallback(0.56f, 0.56f, 0.56f, 1.0f);
Application* GetApplicationFromWindow(HWND hwnd) {
return reinterpret_cast<Application*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
}
std::filesystem::path GetRepoRootPath() {
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return std::filesystem::path(root).lexically_normal();
}
std::string TruncateText(const std::string& text, std::size_t maxLength) {
if (text.size() <= maxLength) {
return text;
}
if (maxLength <= 3u) {
return text.substr(0, maxLength);
}
return text.substr(0, maxLength - 3u) + "...";
}
std::string ExtractStateKeyTail(const std::string& stateKey) {
if (stateKey.empty()) {
return "-";
}
const std::size_t separator = stateKey.find_last_of('/');
if (separator == std::string::npos || separator + 1u >= stateKey.size()) {
return stateKey;
}
return stateKey.substr(separator + 1u);
}
std::string FormatFloat(float value) {
std::ostringstream stream;
stream.setf(std::ios::fixed, std::ios::floatfield);
stream.precision(1);
stream << value;
return stream.str();
}
std::string FormatPoint(const UIPoint& point) {
return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")";
}
std::string FormatRect(const UIRect& rect) {
return "(" + FormatFloat(rect.x) +
", " + FormatFloat(rect.y) +
", " + FormatFloat(rect.width) +
", " + FormatFloat(rect.height) +
")";
}
std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) {
switch (wParam) {
case 'A': return static_cast<std::int32_t>(KeyCode::A);
case 'B': return static_cast<std::int32_t>(KeyCode::B);
case 'C': return static_cast<std::int32_t>(KeyCode::C);
case 'D': return static_cast<std::int32_t>(KeyCode::D);
case 'E': return static_cast<std::int32_t>(KeyCode::E);
case 'F': return static_cast<std::int32_t>(KeyCode::F);
case 'G': return static_cast<std::int32_t>(KeyCode::G);
case 'H': return static_cast<std::int32_t>(KeyCode::H);
case 'I': return static_cast<std::int32_t>(KeyCode::I);
case 'J': return static_cast<std::int32_t>(KeyCode::J);
case 'K': return static_cast<std::int32_t>(KeyCode::K);
case 'L': return static_cast<std::int32_t>(KeyCode::L);
case 'M': return static_cast<std::int32_t>(KeyCode::M);
case 'N': return static_cast<std::int32_t>(KeyCode::N);
case 'O': return static_cast<std::int32_t>(KeyCode::O);
case 'P': return static_cast<std::int32_t>(KeyCode::P);
case 'Q': return static_cast<std::int32_t>(KeyCode::Q);
case 'R': return static_cast<std::int32_t>(KeyCode::R);
case 'S': return static_cast<std::int32_t>(KeyCode::S);
case 'T': return static_cast<std::int32_t>(KeyCode::T);
case 'U': return static_cast<std::int32_t>(KeyCode::U);
case 'V': return static_cast<std::int32_t>(KeyCode::V);
case 'W': return static_cast<std::int32_t>(KeyCode::W);
case 'X': return static_cast<std::int32_t>(KeyCode::X);
case 'Y': return static_cast<std::int32_t>(KeyCode::Y);
case 'Z': return static_cast<std::int32_t>(KeyCode::Z);
case '0': return static_cast<std::int32_t>(KeyCode::Zero);
case '1': return static_cast<std::int32_t>(KeyCode::One);
case '2': return static_cast<std::int32_t>(KeyCode::Two);
case '3': return static_cast<std::int32_t>(KeyCode::Three);
case '4': return static_cast<std::int32_t>(KeyCode::Four);
case '5': return static_cast<std::int32_t>(KeyCode::Five);
case '6': return static_cast<std::int32_t>(KeyCode::Six);
case '7': return static_cast<std::int32_t>(KeyCode::Seven);
case '8': return static_cast<std::int32_t>(KeyCode::Eight);
case '9': return static_cast<std::int32_t>(KeyCode::Nine);
case VK_SPACE: return static_cast<std::int32_t>(KeyCode::Space);
case VK_TAB: return static_cast<std::int32_t>(KeyCode::Tab);
case VK_RETURN: return static_cast<std::int32_t>(KeyCode::Enter);
case VK_ESCAPE: return static_cast<std::int32_t>(KeyCode::Escape);
case VK_SHIFT: return static_cast<std::int32_t>(KeyCode::LeftShift);
case VK_CONTROL: return static_cast<std::int32_t>(KeyCode::LeftCtrl);
case VK_MENU: return static_cast<std::int32_t>(KeyCode::LeftAlt);
case VK_UP: return static_cast<std::int32_t>(KeyCode::Up);
case VK_DOWN: return static_cast<std::int32_t>(KeyCode::Down);
case VK_LEFT: return static_cast<std::int32_t>(KeyCode::Left);
case VK_RIGHT: return static_cast<std::int32_t>(KeyCode::Right);
case VK_HOME: return static_cast<std::int32_t>(KeyCode::Home);
case VK_END: return static_cast<std::int32_t>(KeyCode::End);
case VK_PRIOR: return static_cast<std::int32_t>(KeyCode::PageUp);
case VK_NEXT: return static_cast<std::int32_t>(KeyCode::PageDown);
case VK_DELETE: return static_cast<std::int32_t>(KeyCode::Delete);
case VK_BACK: return static_cast<std::int32_t>(KeyCode::Backspace);
case VK_F1: return static_cast<std::int32_t>(KeyCode::F1);
case VK_F2: return static_cast<std::int32_t>(KeyCode::F2);
case VK_F3: return static_cast<std::int32_t>(KeyCode::F3);
case VK_F4: return static_cast<std::int32_t>(KeyCode::F4);
case VK_F5: return static_cast<std::int32_t>(KeyCode::F5);
case VK_F6: return static_cast<std::int32_t>(KeyCode::F6);
case VK_F7: return static_cast<std::int32_t>(KeyCode::F7);
case VK_F8: return static_cast<std::int32_t>(KeyCode::F8);
case VK_F9: return static_cast<std::int32_t>(KeyCode::F9);
case VK_F10: return static_cast<std::int32_t>(KeyCode::F10);
case VK_F11: return static_cast<std::int32_t>(KeyCode::F11);
case VK_F12: return static_cast<std::int32_t>(KeyCode::F12);
default: return static_cast<std::int32_t>(KeyCode::None);
}
}
bool IsRepeatKeyMessage(LPARAM lParam) {
return (static_cast<unsigned long>(lParam) & (1ul << 30)) != 0ul;
}
} // namespace
Application::Application(std::string requestedScenarioId)
: m_screenPlayer(m_documentHost)
, m_requestedScenarioId(std::move(requestedScenarioId)) {
}
int Application::Run(HINSTANCE hInstance, int nCmdShow) {
if (!Initialize(hInstance, nCmdShow)) {
Shutdown();
return 1;
}
MSG message = {};
while (message.message != WM_QUIT) {
if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) {
TranslateMessage(&message);
DispatchMessageW(&message);
continue;
}
RenderFrame();
Sleep(8);
}
Shutdown();
return static_cast<int>(message.wParam);
}
bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
m_hInstance = hInstance;
WNDCLASSEXW windowClass = {};
windowClass.cbSize = sizeof(windowClass);
windowClass.style = CS_HREDRAW | CS_VREDRAW;
windowClass.lpfnWndProc = &Application::WndProc;
windowClass.hInstance = hInstance;
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
windowClass.lpszClassName = kWindowClassName;
m_windowClassAtom = RegisterClassExW(&windowClass);
if (m_windowClassAtom == 0) {
return false;
}
m_hwnd = CreateWindowExW(
0,
kWindowClassName,
kWindowTitle,
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
1440,
900,
nullptr,
nullptr,
hInstance,
this);
if (m_hwnd == nullptr) {
return false;
}
ShowWindow(m_hwnd, nCmdShow);
UpdateWindow(m_hwnd);
if (!m_renderer.Initialize(m_hwnd)) {
return false;
}
m_startTime = std::chrono::steady_clock::now();
m_lastFrameTime = m_startTime;
const EditorValidationScenario* initialScenario = m_requestedScenarioId.empty()
? &GetDefaultEditorValidationScenario()
: FindEditorValidationScenario(m_requestedScenarioId);
if (initialScenario == nullptr) {
initialScenario = &GetDefaultEditorValidationScenario();
}
m_autoScreenshot.Initialize(initialScenario->captureRootPath);
LoadStructuredScreen("startup");
return true;
}
void Application::Shutdown() {
m_autoScreenshot.Shutdown();
m_screenPlayer.Unload();
m_trackedFiles.clear();
m_screenAsset = {};
m_useStructuredScreen = false;
m_runtimeStatus.clear();
m_runtimeError.clear();
m_frameIndex = 0;
m_renderer.Shutdown();
if (m_hwnd != nullptr && IsWindow(m_hwnd)) {
DestroyWindow(m_hwnd);
}
m_hwnd = nullptr;
if (m_windowClassAtom != 0 && m_hInstance != nullptr) {
UnregisterClassW(kWindowClassName, m_hInstance);
m_windowClassAtom = 0;
}
}
void Application::RenderFrame() {
if (m_hwnd == nullptr) {
return;
}
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(clientRect.right - clientRect.left, 1L));
const float height = static_cast<float>((std::max)(clientRect.bottom - clientRect.top, 1L));
const auto now = std::chrono::steady_clock::now();
double deltaTimeSeconds = std::chrono::duration<double>(now - m_lastFrameTime).count();
if (deltaTimeSeconds <= 0.0) {
deltaTimeSeconds = 1.0 / 60.0;
}
m_lastFrameTime = now;
RefreshStructuredScreen();
std::vector<UIInputEvent> frameEvents = std::move(m_pendingInputEvents);
m_pendingInputEvents.clear();
UIDrawData drawData = {};
if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) {
UIScreenFrameInput input = {};
input.viewportRect = UIRect(0.0f, 0.0f, width, height);
input.events = std::move(frameEvents);
input.deltaTimeSeconds = deltaTimeSeconds;
input.frameIndex = ++m_frameIndex;
input.focused = GetForegroundWindow() == m_hwnd;
const auto& frame = m_screenPlayer.Update(input);
for (const auto& drawList : frame.drawData.GetDrawLists()) {
drawData.AddDrawList(drawList);
}
m_runtimeStatus = m_activeScenario != nullptr
? m_activeScenario->displayName
: "Editor UI Validation";
m_runtimeError = frame.errorMessage;
}
if (drawData.Empty()) {
m_runtimeStatus = "Editor UI Validation | Load Error";
if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) {
m_runtimeError = m_screenPlayer.GetLastError();
}
}
AppendRuntimeOverlay(drawData, width, height);
const bool framePresented = m_renderer.Render(drawData);
m_autoScreenshot.CaptureIfRequested(
m_renderer,
drawData,
static_cast<unsigned int>(width),
static_cast<unsigned int>(height),
framePresented);
}
void Application::OnResize(UINT width, UINT height) {
if (width == 0 || height == 0) {
return;
}
m_renderer.Resize(width, height);
}
void Application::QueuePointerEvent(UIInputEventType type, UIPointerButton button, WPARAM wParam, LPARAM lParam) {
UIInputEvent event = {};
event.type = type;
event.pointerButton = button;
event.position = UIPoint(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)));
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<size_t>(wParam));
m_pendingInputEvents.push_back(event);
}
void Application::QueuePointerLeaveEvent() {
UIInputEvent event = {};
event.type = UIInputEventType::PointerLeave;
if (m_hwnd != nullptr) {
POINT clientPoint = {};
GetCursorPos(&clientPoint);
ScreenToClient(m_hwnd, &clientPoint);
event.position = UIPoint(static_cast<float>(clientPoint.x), static_cast<float>(clientPoint.y));
}
m_pendingInputEvents.push_back(event);
}
void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam) {
if (m_hwnd == nullptr) {
return;
}
POINT screenPoint = {
GET_X_LPARAM(lParam),
GET_Y_LPARAM(lParam)
};
ScreenToClient(m_hwnd, &screenPoint);
UIInputEvent event = {};
event.type = UIInputEventType::PointerWheel;
event.position = UIPoint(static_cast<float>(screenPoint.x), static_cast<float>(screenPoint.y));
event.wheelDelta = static_cast<float>(wheelDelta);
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<size_t>(wParam));
m_pendingInputEvents.push_back(event);
}
void Application::QueueKeyEvent(UIInputEventType type, WPARAM wParam, LPARAM lParam) {
UIInputEvent event = {};
event.type = type;
event.keyCode = MapVirtualKeyToUIKeyCode(wParam);
event.modifiers = m_inputModifierTracker.ApplyKeyMessage(type, wParam, lParam);
event.repeat = IsRepeatKeyMessage(lParam);
m_pendingInputEvents.push_back(event);
}
void Application::QueueCharacterEvent(WPARAM wParam, LPARAM) {
UIInputEvent event = {};
event.type = UIInputEventType::Character;
event.character = static_cast<std::uint32_t>(wParam);
event.modifiers = m_inputModifierTracker.GetCurrentModifiers();
m_pendingInputEvents.push_back(event);
}
void Application::QueueWindowFocusEvent(UIInputEventType type) {
UIInputEvent event = {};
event.type = type;
m_pendingInputEvents.push_back(event);
}
bool Application::LoadStructuredScreen(const char* triggerReason) {
(void)triggerReason;
std::string scenarioLoadWarning = {};
const EditorValidationScenario* scenario = m_requestedScenarioId.empty()
? &GetDefaultEditorValidationScenario()
: FindEditorValidationScenario(m_requestedScenarioId);
if (scenario == nullptr) {
scenario = &GetDefaultEditorValidationScenario();
scenarioLoadWarning = "Unknown validation scenario: " + m_requestedScenarioId;
}
m_activeScenario = scenario;
m_screenAsset = {};
m_screenAsset.screenId = scenario->id;
m_screenAsset.documentPath = scenario->documentPath.string();
m_screenAsset.themePath = scenario->themePath.string();
const bool loaded = m_screenPlayer.Load(m_screenAsset);
m_useStructuredScreen = loaded;
m_runtimeStatus = loaded ? scenario->displayName : "Editor UI Validation | Load Error";
m_runtimeError = loaded
? scenarioLoadWarning
: (scenarioLoadWarning.empty()
? m_screenPlayer.GetLastError()
: scenarioLoadWarning + " | " + m_screenPlayer.GetLastError());
RebuildTrackedFileStates();
return loaded;
}
void Application::RefreshStructuredScreen() {
const auto now = std::chrono::steady_clock::now();
if (m_lastReloadPollTime.time_since_epoch().count() != 0 &&
now - m_lastReloadPollTime < kReloadPollInterval) {
return;
}
m_lastReloadPollTime = now;
if (DetectTrackedFileChange()) {
LoadStructuredScreen("reload");
}
}
void Application::RebuildTrackedFileStates() {
namespace fs = std::filesystem;
m_trackedFiles.clear();
std::unordered_set<std::string> seenPaths = {};
std::error_code errorCode = {};
auto appendTrackedPath = [&](const std::string& rawPath) {
if (rawPath.empty()) {
return;
}
const fs::path normalizedPath = fs::path(rawPath).lexically_normal();
const std::string key = normalizedPath.string();
if (!seenPaths.insert(key).second) {
return;
}
TrackedFileState state = {};
state.path = normalizedPath;
state.exists = fs::exists(normalizedPath, errorCode);
errorCode.clear();
if (state.exists) {
state.writeTime = fs::last_write_time(normalizedPath, errorCode);
errorCode.clear();
}
m_trackedFiles.push_back(std::move(state));
};
appendTrackedPath(m_screenAsset.documentPath);
appendTrackedPath(m_screenAsset.themePath);
if (const auto* document = m_screenPlayer.GetDocument(); document != nullptr) {
for (const std::string& dependency : document->dependencies) {
appendTrackedPath(dependency);
}
}
}
bool Application::DetectTrackedFileChange() const {
namespace fs = std::filesystem;
std::error_code errorCode = {};
for (const TrackedFileState& trackedFile : m_trackedFiles) {
const bool existsNow = fs::exists(trackedFile.path, errorCode);
errorCode.clear();
if (existsNow != trackedFile.exists) {
return true;
}
if (!existsNow) {
continue;
}
const auto writeTimeNow = fs::last_write_time(trackedFile.path, errorCode);
errorCode.clear();
if (writeTimeNow != trackedFile.writeTime) {
return true;
}
}
return false;
}
void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const {
const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded();
const float panelWidth = authoredMode ? 460.0f : 360.0f;
std::vector<std::string> detailLines = {};
detailLines.push_back(
authoredMode
? "Hot reload watches authored UI resources."
: "Authored validation scene failed to load.");
if (m_activeScenario != nullptr) {
detailLines.push_back("Scenario: " + m_activeScenario->id);
}
if (authoredMode) {
const auto& inputDebug = m_documentHost.GetInputDebugSnapshot();
detailLines.push_back(
"Hover | Focus: " +
ExtractStateKeyTail(inputDebug.hoveredStateKey) +
" | " +
ExtractStateKeyTail(inputDebug.focusedStateKey));
detailLines.push_back(
"Active | Capture: " +
ExtractStateKeyTail(inputDebug.activeStateKey) +
" | " +
ExtractStateKeyTail(inputDebug.captureStateKey));
detailLines.push_back(
"Scope W/P/Wg: " +
ExtractStateKeyTail(inputDebug.windowScopeStateKey) +
" | " +
ExtractStateKeyTail(inputDebug.panelScopeStateKey) +
" | " +
ExtractStateKeyTail(inputDebug.widgetScopeStateKey));
detailLines.push_back(
std::string("Text input: ") +
(inputDebug.textInputActive ? "active" : "idle"));
if (!inputDebug.recentShortcutCommandId.empty()) {
detailLines.push_back(
"Recent shortcut: " +
inputDebug.recentShortcutScope +
" -> " +
inputDebug.recentShortcutCommandId);
detailLines.push_back(
std::string("Recent shortcut state: ") +
(inputDebug.recentShortcutHandled
? "handled"
: (inputDebug.recentShortcutSuppressed ? "suppressed" : "observed")) +
" @ " +
ExtractStateKeyTail(inputDebug.recentShortcutOwnerStateKey));
} else {
detailLines.push_back("Recent shortcut: none");
}
if (!inputDebug.lastEventType.empty()) {
const std::string eventPosition = inputDebug.lastEventType == "KeyDown" ||
inputDebug.lastEventType == "KeyUp" ||
inputDebug.lastEventType == "Character" ||
inputDebug.lastEventType == "FocusGained" ||
inputDebug.lastEventType == "FocusLost"
? std::string()
: " at " + FormatPoint(inputDebug.pointerPosition);
detailLines.push_back(
"Last input: " +
inputDebug.lastEventType +
eventPosition);
detailLines.push_back(
"Route: " +
inputDebug.lastTargetKind +
" -> " +
ExtractStateKeyTail(inputDebug.lastTargetStateKey));
if (!inputDebug.lastShortcutCommandId.empty()) {
detailLines.push_back(
"Shortcut: " +
inputDebug.lastShortcutScope +
" -> " +
inputDebug.lastShortcutCommandId);
detailLines.push_back(
std::string("Shortcut state: ") +
(inputDebug.lastShortcutHandled
? "handled"
: (inputDebug.lastShortcutSuppressed ? "suppressed" : "observed")) +
" @ " +
ExtractStateKeyTail(inputDebug.lastShortcutOwnerStateKey));
}
detailLines.push_back(
"Last event result: " +
(inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult));
}
}
if (m_autoScreenshot.HasPendingCapture()) {
detailLines.push_back("Shot pending...");
} else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) {
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureSummary(), 78u));
} else {
detailLines.push_back("Screenshots: F12 -> current scenario captures/");
}
if (!m_runtimeError.empty()) {
detailLines.push_back(TruncateText(m_runtimeError, 78u));
} else if (!m_autoScreenshot.GetLastCaptureError().empty()) {
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureError(), 78u));
} else if (!authoredMode) {
detailLines.push_back("No fallback sandbox is rendered in this host.");
}
const float panelHeight = 38.0f + static_cast<float>(detailLines.size()) * 18.0f;
const UIRect panelRect(width - panelWidth - 16.0f, height - panelHeight - 42.0f, panelWidth, panelHeight);
UIDrawList& overlay = drawData.EmplaceDrawList("Editor UI Validation Overlay");
overlay.AddFilledRect(panelRect, kOverlayBgColor, 10.0f);
overlay.AddRectOutline(panelRect, kOverlayBorderColor, 1.0f, 10.0f);
overlay.AddFilledRect(
UIRect(panelRect.x + 12.0f, panelRect.y + 14.0f, 8.0f, 8.0f),
authoredMode ? kOverlaySuccess : kOverlayFallback,
4.0f);
overlay.AddText(
UIPoint(panelRect.x + 28.0f, panelRect.y + 10.0f),
m_runtimeStatus.empty() ? "Editor UI Validation" : m_runtimeStatus,
kOverlayTextPrimary,
14.0f);
float detailY = panelRect.y + 30.0f;
for (std::size_t index = 0; index < detailLines.size(); ++index) {
const bool lastLine = index + 1u == detailLines.size();
overlay.AddText(
UIPoint(panelRect.x + 28.0f, detailY),
detailLines[index],
lastLine && (!m_runtimeError.empty() || !m_autoScreenshot.GetLastCaptureError().empty())
? kOverlayFallback
: kOverlayTextMuted,
12.0f);
detailY += 18.0f;
}
}
std::filesystem::path Application::ResolveRepoRelativePath(const char* relativePath) {
return (GetRepoRootPath() / relativePath).lexically_normal();
}
LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
if (message == WM_NCCREATE) {
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
auto* application = reinterpret_cast<Application*>(createStruct->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(application));
return TRUE;
}
Application* application = GetApplicationFromWindow(hwnd);
switch (message) {
case WM_SIZE:
if (application != nullptr && wParam != SIZE_MINIMIZED) {
application->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
}
return 0;
case WM_PAINT:
if (application != nullptr) {
PAINTSTRUCT paintStruct = {};
BeginPaint(hwnd, &paintStruct);
application->RenderFrame();
EndPaint(hwnd, &paintStruct);
return 0;
}
break;
case WM_MOUSEMOVE:
if (application != nullptr) {
if (!application->m_trackingMouseLeave) {
TRACKMOUSEEVENT trackMouseEvent = {};
trackMouseEvent.cbSize = sizeof(trackMouseEvent);
trackMouseEvent.dwFlags = TME_LEAVE;
trackMouseEvent.hwndTrack = hwnd;
if (TrackMouseEvent(&trackMouseEvent)) {
application->m_trackingMouseLeave = true;
}
}
application->QueuePointerEvent(UIInputEventType::PointerMove, UIPointerButton::None, wParam, lParam);
return 0;
}
break;
case WM_MOUSELEAVE:
if (application != nullptr) {
application->m_trackingMouseLeave = false;
application->QueuePointerLeaveEvent();
return 0;
}
break;
case WM_LBUTTONDOWN:
if (application != nullptr) {
SetFocus(hwnd);
SetCapture(hwnd);
application->QueuePointerEvent(UIInputEventType::PointerButtonDown, UIPointerButton::Left, wParam, lParam);
return 0;
}
break;
case WM_LBUTTONUP:
if (application != nullptr) {
if (GetCapture() == hwnd) {
ReleaseCapture();
}
application->QueuePointerEvent(UIInputEventType::PointerButtonUp, UIPointerButton::Left, wParam, lParam);
return 0;
}
break;
case WM_MOUSEWHEEL:
if (application != nullptr) {
application->QueuePointerWheelEvent(GET_WHEEL_DELTA_WPARAM(wParam), wParam, lParam);
return 0;
}
break;
case WM_SETFOCUS:
if (application != nullptr) {
application->m_inputModifierTracker.SyncFromSystemState();
application->QueueWindowFocusEvent(UIInputEventType::FocusGained);
return 0;
}
break;
case WM_KILLFOCUS:
if (application != nullptr) {
application->m_inputModifierTracker.Reset();
application->QueueWindowFocusEvent(UIInputEventType::FocusLost);
return 0;
}
break;
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
if (application != nullptr) {
if (wParam == VK_F12) {
application->m_autoScreenshot.RequestCapture("manual_f12");
}
application->QueueKeyEvent(UIInputEventType::KeyDown, wParam, lParam);
return 0;
}
break;
case WM_KEYUP:
case WM_SYSKEYUP:
if (application != nullptr) {
application->QueueKeyEvent(UIInputEventType::KeyUp, wParam, lParam);
return 0;
}
break;
case WM_CHAR:
if (application != nullptr) {
application->QueueCharacterEvent(wParam, lParam);
return 0;
}
break;
case WM_ERASEBKGND:
return 1;
case WM_DESTROY:
if (application != nullptr) {
application->m_hwnd = nullptr;
}
PostQuitMessage(0);
return 0;
default:
break;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
int RunEditorUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId) {
Application application(std::move(requestedScenarioId));
return application.Run(hInstance, nCmdShow);
}
} // namespace XCEngine::Tests::EditorUI

View File

@@ -0,0 +1,83 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include "EditorValidationScenario.h"
#include <XCNewEditor/Host/AutoScreenshot.h>
#include <XCNewEditor/Host/InputModifierTracker.h>
#include <XCNewEditor/Host/NativeRenderer.h>
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <windows.h>
#include <windowsx.h>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace XCEngine::Tests::EditorUI {
class Application {
public:
explicit Application(std::string requestedScenarioId = {});
int Run(HINSTANCE hInstance, int nCmdShow);
private:
struct TrackedFileState {
std::filesystem::path path = {};
std::filesystem::file_time_type writeTime = {};
bool exists = false;
};
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
bool Initialize(HINSTANCE hInstance, int nCmdShow);
void Shutdown();
void RenderFrame();
void OnResize(UINT width, UINT height);
void QueuePointerEvent(::XCEngine::UI::UIInputEventType type, ::XCEngine::UI::UIPointerButton button, WPARAM wParam, LPARAM lParam);
void QueuePointerLeaveEvent();
void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam);
void QueueKeyEvent(::XCEngine::UI::UIInputEventType type, WPARAM wParam, LPARAM lParam);
void QueueCharacterEvent(WPARAM wParam, LPARAM lParam);
void QueueWindowFocusEvent(::XCEngine::UI::UIInputEventType type);
bool LoadStructuredScreen(const char* triggerReason);
void RefreshStructuredScreen();
void RebuildTrackedFileStates();
bool DetectTrackedFileChange() const;
void AppendRuntimeOverlay(::XCEngine::UI::UIDrawData& drawData, float width, float height) const;
static std::filesystem::path ResolveRepoRelativePath(const char* relativePath);
HWND m_hwnd = nullptr;
HINSTANCE m_hInstance = nullptr;
ATOM m_windowClassAtom = 0;
::XCEngine::XCUI::Host::NativeRenderer m_renderer;
::XCEngine::XCUI::Host::AutoScreenshotController m_autoScreenshot;
::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost;
::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer;
::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {};
const EditorValidationScenario* m_activeScenario = nullptr;
std::string m_requestedScenarioId = {};
std::vector<TrackedFileState> m_trackedFiles = {};
std::chrono::steady_clock::time_point m_startTime = {};
std::chrono::steady_clock::time_point m_lastFrameTime = {};
std::chrono::steady_clock::time_point m_lastReloadPollTime = {};
std::uint64_t m_frameIndex = 0;
std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {};
::XCEngine::XCUI::Host::InputModifierTracker m_inputModifierTracker = {};
bool m_trackingMouseLeave = false;
bool m_useStructuredScreen = false;
std::string m_runtimeStatus = {};
std::string m_runtimeError = {};
};
int RunEditorUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId = {});
} // namespace XCEngine::Tests::EditorUI

View File

@@ -0,0 +1,85 @@
#include "EditorValidationScenario.h"
#include <array>
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
#endif
namespace XCEngine::Tests::EditorUI {
namespace {
namespace fs = std::filesystem;
fs::path RepoRootPath() {
std::string root = XCENGINE_EDITOR_UI_TESTS_REPO_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return fs::path(root).lexically_normal();
}
fs::path RepoRelative(const char* relativePath) {
return (RepoRootPath() / relativePath).lexically_normal();
}
const std::array<EditorValidationScenario, 4>& GetEditorValidationScenarios() {
static const std::array<EditorValidationScenario, 4> scenarios = { {
{
"editor.input.keyboard_focus",
UIValidationDomain::Editor,
"input",
"Editor Input | Keyboard Focus",
RepoRelative("tests/UI/Editor/integration/input/keyboard_focus/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/input/keyboard_focus/captures")
},
{
"editor.input.pointer_states",
UIValidationDomain::Editor,
"input",
"Editor Input | Pointer States",
RepoRelative("tests/UI/Editor/integration/input/pointer_states/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/input/pointer_states/captures")
},
{
"editor.input.shortcut_scope",
UIValidationDomain::Editor,
"input",
"Editor Input | Shortcut Scope",
RepoRelative("tests/UI/Editor/integration/input/shortcut_scope/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/input/shortcut_scope/captures")
},
{
"editor.layout.splitter_resize",
UIValidationDomain::Editor,
"layout",
"Editor Layout | Splitter Resize",
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/captures")
}
} };
return scenarios;
}
} // namespace
const EditorValidationScenario& GetDefaultEditorValidationScenario() {
return GetEditorValidationScenarios().front();
}
const EditorValidationScenario* FindEditorValidationScenario(std::string_view id) {
for (const EditorValidationScenario& scenario : GetEditorValidationScenarios()) {
if (scenario.id == id) {
return &scenario;
}
}
return nullptr;
}
} // namespace XCEngine::Tests::EditorUI

View File

@@ -0,0 +1,27 @@
#pragma once
#include <filesystem>
#include <string>
#include <string_view>
namespace XCEngine::Tests::EditorUI {
enum class UIValidationDomain : unsigned char {
Editor = 0,
Runtime
};
struct EditorValidationScenario {
std::string id = {};
UIValidationDomain domain = UIValidationDomain::Editor;
std::string categoryId = {};
std::string displayName = {};
std::filesystem::path documentPath = {};
std::filesystem::path themePath = {};
std::filesystem::path captureRootPath = {};
};
const EditorValidationScenario& GetDefaultEditorValidationScenario();
const EditorValidationScenario* FindEditorValidationScenario(std::string_view id);
} // namespace XCEngine::Tests::EditorUI

View File

@@ -0,0 +1,32 @@
<Theme name="EditorValidationTheme">
<Tokens>
<Color name="color.bg.workspace" value="#1C1C1C" />
<Color name="color.bg.panel" value="#292929" />
<Color name="color.bg.accent" value="#3A3A3A" />
<Color name="color.bg.selection" value="#4A4A4A" />
<Color name="color.text.primary" value="#EEEEEE" />
<Color name="color.text.muted" value="#B0B0B0" />
<Spacing name="space.panel" value="12" />
<Spacing name="space.shell" value="18" />
<Radius name="radius.panel" value="10" />
<Radius name="radius.control" value="8" />
</Tokens>
<Widgets>
<Widget type="View" style="EditorWorkspace">
<Property name="background" value="color.bg.workspace" />
<Property name="padding" value="space.shell" />
</Widget>
<Widget type="Card" style="EditorPanel">
<Property name="background" value="color.bg.panel" />
<Property name="radius" value="radius.panel" />
<Property name="padding" value="space.panel" />
</Widget>
<Widget type="Button" style="EditorChip">
<Property name="background" value="color.bg.selection" />
<Property name="radius" value="radius.control" />
</Widget>
</Widgets>
</Theme>

View File

@@ -0,0 +1,35 @@
set(EDITOR_UI_UNIT_TEST_SOURCES
test_input_modifier_tracker.cpp
test_editor_validation_registry.cpp
test_structured_editor_shell.cpp
# Migration bridge: editor-facing XCUI primitive tests still reuse the
# legacy source location until they are relocated under tests/UI/Editor/unit.
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_collection_primitives.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_panel_chrome.cpp
)
add_executable(editor_ui_tests ${EDITOR_UI_UNIT_TEST_SOURCES})
target_link_libraries(editor_ui_tests
PRIVATE
editor_ui_validation_registry
XCNewEditorLib
GTest::gtest_main
)
target_include_directories(editor_ui_tests
PRIVATE
${CMAKE_SOURCE_DIR}/new_editor/include
${CMAKE_SOURCE_DIR}/new_editor/src
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src
)
if(MSVC)
target_compile_options(editor_ui_tests PRIVATE /utf-8 /FS)
set_property(TARGET editor_ui_tests PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
include(GoogleTest)
gtest_discover_tests(editor_ui_tests)

View File

@@ -0,0 +1,48 @@
#include <gtest/gtest.h>
#include "EditorValidationScenario.h"
#include <filesystem>
namespace {
using XCEngine::Tests::EditorUI::FindEditorValidationScenario;
using XCEngine::Tests::EditorUI::GetDefaultEditorValidationScenario;
using XCEngine::Tests::EditorUI::UIValidationDomain;
} // namespace
TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExistingResources) {
const auto* pointerScenario = FindEditorValidationScenario("editor.input.pointer_states");
const auto* keyboardScenario = FindEditorValidationScenario("editor.input.keyboard_focus");
const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope");
const auto* splitterScenario = FindEditorValidationScenario("editor.layout.splitter_resize");
ASSERT_NE(pointerScenario, nullptr);
ASSERT_NE(keyboardScenario, nullptr);
ASSERT_NE(shortcutScenario, nullptr);
ASSERT_NE(splitterScenario, nullptr);
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(pointerScenario->categoryId, "input");
EXPECT_EQ(keyboardScenario->categoryId, "input");
EXPECT_EQ(shortcutScenario->categoryId, "input");
EXPECT_EQ(splitterScenario->categoryId, "layout");
EXPECT_TRUE(std::filesystem::exists(pointerScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(shortcutScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(shortcutScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(splitterScenario->themePath));
}
TEST(EditorValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) {
const auto& scenario = GetDefaultEditorValidationScenario();
EXPECT_EQ(scenario.id, "editor.input.keyboard_focus");
EXPECT_EQ(scenario.domain, UIValidationDomain::Editor);
EXPECT_TRUE(std::filesystem::exists(scenario.documentPath));
}

View File

@@ -0,0 +1,90 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <gtest/gtest.h>
#include <XCNewEditor/Host/InputModifierTracker.h>
#include <XCEngine/UI/Types.h>
#include <windows.h>
namespace {
using XCEngine::XCUI::Host::InputModifierTracker;
using XCEngine::UI::UIInputEventType;
TEST(InputModifierTrackerTest, ControlStatePersistsAcrossChordKeyDownAndClearsOnKeyUp) {
InputModifierTracker tracker = {};
const auto ctrlDown = tracker.ApplyKeyMessage(
UIInputEventType::KeyDown,
VK_CONTROL,
0x001D0001);
EXPECT_TRUE(ctrlDown.control);
EXPECT_FALSE(ctrlDown.shift);
EXPECT_FALSE(ctrlDown.alt);
const auto chordKeyDown = tracker.ApplyKeyMessage(
UIInputEventType::KeyDown,
'P',
0x00190001);
EXPECT_TRUE(chordKeyDown.control);
const auto ctrlUp = tracker.ApplyKeyMessage(
UIInputEventType::KeyUp,
VK_CONTROL,
static_cast<LPARAM>(0xC01D0001u));
EXPECT_FALSE(ctrlUp.control);
const auto nextKeyDown = tracker.ApplyKeyMessage(
UIInputEventType::KeyDown,
'P',
0x00190001);
EXPECT_FALSE(nextKeyDown.control);
}
TEST(InputModifierTrackerTest, PointerModifiersMergeMouseFlagsWithTrackedKeyboardState) {
InputModifierTracker tracker = {};
tracker.ApplyKeyMessage(
UIInputEventType::KeyDown,
VK_MENU,
0x00380001);
const auto modifiers = tracker.BuildPointerModifiers(MK_SHIFT);
EXPECT_TRUE(modifiers.shift);
EXPECT_TRUE(modifiers.alt);
EXPECT_FALSE(modifiers.control);
EXPECT_FALSE(modifiers.super);
}
TEST(InputModifierTrackerTest, RightControlIsTrackedIndependentlyFromLeftControl) {
InputModifierTracker tracker = {};
tracker.ApplyKeyMessage(
UIInputEventType::KeyDown,
VK_CONTROL,
static_cast<LPARAM>(0x011D0001u));
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
tracker.ApplyKeyMessage(
UIInputEventType::KeyDown,
VK_CONTROL,
0x001D0001);
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
tracker.ApplyKeyMessage(
UIInputEventType::KeyUp,
VK_CONTROL,
static_cast<LPARAM>(0xC11D0001u));
EXPECT_TRUE(tracker.GetCurrentModifiers().control);
tracker.ApplyKeyMessage(
UIInputEventType::KeyUp,
VK_CONTROL,
static_cast<LPARAM>(0xC01D0001u));
EXPECT_FALSE(tracker.GetCurrentModifiers().control);
}
} // namespace

View File

@@ -0,0 +1,93 @@
#include <gtest/gtest.h>
#include "editor/EditorShellAsset.h"
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <filesystem>
#include <string>
#include <vector>
#ifndef XCNEWEDITOR_REPO_ROOT
#define XCNEWEDITOR_REPO_ROOT "."
#endif
namespace {
using XCEngine::NewEditor::BuildDefaultEditorShellAsset;
using XCEngine::UI::UIDrawCommand;
using XCEngine::UI::UIDrawCommandType;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::Runtime::UIScreenAsset;
using XCEngine::UI::Runtime::UIScreenFrameInput;
using XCEngine::UI::Runtime::UIScreenPlayer;
using XCEngine::UI::Runtime::UIDocumentScreenHost;
std::filesystem::path RepoRootPath() {
std::string root = XCNEWEDITOR_REPO_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return std::filesystem::path(root).lexically_normal();
}
bool DrawDataContainsText(const UIDrawData& drawData, const std::string& text) {
for (const auto& drawList : drawData.GetDrawLists()) {
for (const UIDrawCommand& command : drawList.GetCommands()) {
if (command.type == UIDrawCommandType::Text && command.text == text) {
return true;
}
}
}
return false;
}
bool ContainsPathWithFilename(
const std::vector<std::string>& paths,
const char* expectedFileName) {
for (const std::string& path : paths) {
if (std::filesystem::path(path).filename() == expectedFileName) {
return true;
}
}
return false;
}
} // namespace
TEST(EditorUIStructuredShellTest, AuthoredEditorShellLoadsFromRepositoryResources) {
const auto shell = BuildDefaultEditorShellAsset(RepoRootPath());
ASSERT_TRUE(std::filesystem::exists(shell.documentPath));
ASSERT_TRUE(std::filesystem::exists(shell.themePath));
UIScreenAsset asset = {};
asset.screenId = shell.screenId;
asset.documentPath = shell.documentPath.string();
asset.themePath = shell.themePath.string();
UIDocumentScreenHost host = {};
UIScreenPlayer player(host);
ASSERT_TRUE(player.Load(asset)) << player.GetLastError();
ASSERT_NE(player.GetDocument(), nullptr);
EXPECT_TRUE(player.GetDocument()->hasThemeDocument);
EXPECT_TRUE(ContainsPathWithFilename(player.GetDocument()->dependencies, "editor_shell.xctheme"));
UIScreenFrameInput input = {};
input.viewportRect = XCEngine::UI::UIRect(0.0f, 0.0f, 1440.0f, 900.0f);
input.frameIndex = 1u;
input.focused = true;
const auto& frame = player.Update(input);
EXPECT_TRUE(frame.stats.documentLoaded);
EXPECT_GE(frame.stats.nodeCount, 2u);
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "XCUI Editor Layer"));
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Left Pane Host"));
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Primary Workspace Host"));
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Right Pane Host"));
EXPECT_FALSE(DrawDataContainsText(frame.drawData, "Bottom Pane Host"));
}

View File

@@ -0,0 +1,17 @@
cmake_minimum_required(VERSION 3.15)
project(XCEngine_RuntimeUITests)
add_subdirectory(unit)
add_subdirectory(integration)
add_custom_target(runtime_ui_unit_tests
DEPENDS
runtime_ui_tests
)
add_custom_target(runtime_ui_all_tests
DEPENDS
runtime_ui_unit_tests
runtime_ui_integration_tests
)

View File

@@ -0,0 +1 @@
add_custom_target(runtime_ui_integration_tests)

View File

@@ -0,0 +1,11 @@
# Runtime UI Integration Validation
This directory is reserved for interactive XCUI runtime validation apps.
Planned scope:
- HUD and menu layer-stack validation
- Runtime input routing and blocking rules
- Screen stack and modal transitions
For now the runtime UI lane only has automated unit coverage in `tests/UI/Runtime/unit/`.

View File

@@ -0,0 +1,28 @@
set(RUNTIME_UI_TEST_SOURCES
${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_shortcut_scope.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_splitter_validation.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_runtime.cpp
)
add_executable(runtime_ui_tests ${RUNTIME_UI_TEST_SOURCES})
if(MSVC)
set_target_properties(runtime_ui_tests PROPERTIES
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
)
endif()
target_link_libraries(runtime_ui_tests
PRIVATE
XCEngine
GTest::gtest
GTest::gtest_main
)
target_include_directories(runtime_ui_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/tests/Fixtures
)
include(GoogleTest)
gtest_discover_tests(runtime_ui_tests)

View File

@@ -0,0 +1,305 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <limits>
#include <string>
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIDrawCommand;
using XCEngine::UI::UIDrawCommandType;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::Runtime::UIScreenAsset;
using XCEngine::UI::Runtime::UIScreenFrameInput;
using XCEngine::UI::Runtime::UIScreenPlayer;
using XCEngine::UI::Runtime::UIDocumentScreenHost;
namespace fs = std::filesystem;
class TempFileScope {
public:
TempFileScope(std::string stem, std::string extension, std::string contents) {
const auto uniqueId = std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count());
m_path = fs::temp_directory_path() / (std::move(stem) + "_" + uniqueId + std::move(extension));
std::ofstream output(m_path, std::ios::binary | std::ios::trunc);
output << contents;
}
~TempFileScope() {
std::error_code ec;
fs::remove(m_path, ec);
}
const fs::path& Path() const {
return m_path;
}
private:
fs::path m_path = {};
};
UIScreenAsset BuildScreenAsset(const fs::path& viewPath, const char* screenId) {
UIScreenAsset screen = {};
screen.screenId = screenId;
screen.documentPath = viewPath.string();
return screen;
}
UIScreenFrameInput BuildInputState(std::uint64_t frameIndex = 1u) {
UIScreenFrameInput input = {};
input.viewportRect = UIRect(0.0f, 0.0f, 960.0f, 720.0f);
input.frameIndex = frameIndex;
input.focused = true;
return input;
}
const UIDrawCommand* FindTextCommand(
const XCEngine::UI::UIDrawData& drawData,
const std::string& text) {
for (const auto& drawList : drawData.GetDrawLists()) {
for (const UIDrawCommand& command : drawList.GetCommands()) {
if (command.type == UIDrawCommandType::Text && command.text == text) {
return &command;
}
}
}
return nullptr;
}
bool RectContainsPoint(
const UIRect& rect,
const UIPoint& point) {
return point.x >= rect.x &&
point.x <= rect.x + rect.width &&
point.y >= rect.y &&
point.y <= rect.y + rect.height;
}
bool TryFindSmallestFilledRectContainingPoint(
const XCEngine::UI::UIDrawData& drawData,
const UIPoint& point,
UIRect& outRect) {
bool found = false;
float bestArea = (std::numeric_limits<float>::max)();
for (const auto& drawList : drawData.GetDrawLists()) {
for (const UIDrawCommand& command : drawList.GetCommands()) {
if (command.type != UIDrawCommandType::FilledRect ||
!RectContainsPoint(command.rect, point)) {
continue;
}
const float area = command.rect.width * command.rect.height;
if (!found || area < bestArea) {
outRect = command.rect;
bestArea = area;
found = true;
}
}
}
return found;
}
bool TryFindFilledRectForText(
const XCEngine::UI::UIDrawData& drawData,
const std::string& text,
UIRect& outRect) {
const auto* textCommand = FindTextCommand(drawData, text);
return textCommand != nullptr &&
TryFindSmallestFilledRectContainingPoint(drawData, textCommand->position, outRect);
}
UIPoint GetRectCenter(const UIRect& rect) {
return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f);
}
void FocusButton(
UIScreenPlayer& player,
const UIRect& viewportRect,
std::uint64_t& frameIndex,
const UIPoint& point) {
UIScreenFrameInput downInput = BuildInputState(frameIndex++);
downInput.viewportRect = viewportRect;
XCEngine::UI::UIInputEvent pointerDown = {};
pointerDown.type = XCEngine::UI::UIInputEventType::PointerButtonDown;
pointerDown.pointerButton = XCEngine::UI::UIPointerButton::Left;
pointerDown.position = point;
downInput.events.push_back(pointerDown);
player.Update(downInput);
UIScreenFrameInput upInput = BuildInputState(frameIndex++);
upInput.viewportRect = viewportRect;
XCEngine::UI::UIInputEvent pointerUp = {};
pointerUp.type = XCEngine::UI::UIInputEventType::PointerButtonUp;
pointerUp.pointerButton = XCEngine::UI::UIPointerButton::Left;
pointerUp.position = point;
upInput.events.push_back(pointerUp);
player.Update(upInput);
}
void SendCtrlP(
UIScreenPlayer& player,
const UIRect& viewportRect,
std::uint64_t& frameIndex) {
UIScreenFrameInput input = BuildInputState(frameIndex++);
input.viewportRect = viewportRect;
XCEngine::UI::UIInputEvent event = {};
event.type = XCEngine::UI::UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(KeyCode::P);
event.modifiers.control = true;
input.events.push_back(event);
player.Update(input);
}
void SendTab(
UIScreenPlayer& player,
const UIRect& viewportRect,
std::uint64_t& frameIndex) {
UIScreenFrameInput input = BuildInputState(frameIndex++);
input.viewportRect = viewportRect;
XCEngine::UI::UIInputEvent event = {};
event.type = XCEngine::UI::UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(KeyCode::Tab);
input.events.push_back(event);
player.Update(input);
}
} // namespace
TEST(UIRuntimeShortcutScopeTest, DocumentHostRoutesFocusedShortcutThroughWidgetPanelWindowAndGlobalScopes) {
TempFileScope viewFile(
"xcui_runtime_shortcut_scope",
".xcui",
"<View name=\"Shortcut Scope Test\" shortcut=\"Ctrl+P\" shortcutCommand=\"global.command\" shortcutScope=\"global\">\n"
" <Column id=\"root\" padding=\"18\" gap=\"12\">\n"
" <Button id=\"global-focus\" text=\"Global Focus\" />\n"
" <Card id=\"window-shell\" title=\"Window Scope\" shortcutScopeRoot=\"window\" shortcut=\"Ctrl+P\" shortcutCommand=\"window.command\" shortcutScope=\"window\">\n"
" <Column gap=\"10\">\n"
" <Button id=\"window-focus\" text=\"Window Focus\" />\n"
" <Card id=\"panel-shell\" title=\"Panel Scope\" shortcutScopeRoot=\"panel\" shortcut=\"Ctrl+P\" shortcutCommand=\"panel.command\" shortcutScope=\"panel\">\n"
" <Column gap=\"10\">\n"
" <Button id=\"panel-focus\" text=\"Panel Focus\" />\n"
" <Card id=\"widget-shell\" title=\"Widget Scope\" shortcutScopeRoot=\"widget\" shortcut=\"Ctrl+P\" shortcutCommand=\"widget.command\" shortcutScope=\"widget\">\n"
" <Column gap=\"10\">\n"
" <Button id=\"widget-focus\" text=\"Widget Focus\" />\n"
" </Column>\n"
" </Card>\n"
" </Column>\n"
" </Card>\n"
" </Column>\n"
" </Card>\n"
" </Column>\n"
"</View>\n");
UIDocumentScreenHost host = {};
UIScreenPlayer player(host);
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.shortcut.scope")));
UIScreenFrameInput initialInput = BuildInputState(1u);
initialInput.viewportRect = UIRect(0.0f, 0.0f, 960.0f, 720.0f);
const auto& initialFrame = player.Update(initialInput);
UIRect globalRect = {};
UIRect windowRect = {};
UIRect panelRect = {};
UIRect widgetRect = {};
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Global Focus", globalRect));
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Window Focus", windowRect));
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Panel Focus", panelRect));
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Widget Focus", widgetRect));
std::uint64_t frameIndex = 2u;
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(widgetRect));
SendCtrlP(player, initialInput.viewportRect, frameIndex);
auto inputDebug = host.GetInputDebugSnapshot();
EXPECT_EQ(inputDebug.lastShortcutCommandId, "widget.command");
EXPECT_EQ(inputDebug.lastShortcutScope, "Widget");
EXPECT_TRUE(inputDebug.lastShortcutHandled);
EXPECT_NE(inputDebug.focusedStateKey.find("/widget-focus"), std::string::npos);
EXPECT_NE(inputDebug.widgetScopeStateKey.find("/widget-shell"), std::string::npos);
EXPECT_NE(inputDebug.panelScopeStateKey.find("/panel-shell"), std::string::npos);
EXPECT_NE(inputDebug.windowScopeStateKey.find("/window-shell"), std::string::npos);
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(panelRect));
SendCtrlP(player, initialInput.viewportRect, frameIndex);
inputDebug = host.GetInputDebugSnapshot();
EXPECT_EQ(inputDebug.lastShortcutCommandId, "panel.command");
EXPECT_EQ(inputDebug.lastShortcutScope, "Panel");
EXPECT_TRUE(inputDebug.lastShortcutHandled);
EXPECT_NE(inputDebug.focusedStateKey.find("/panel-focus"), std::string::npos);
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(windowRect));
SendCtrlP(player, initialInput.viewportRect, frameIndex);
inputDebug = host.GetInputDebugSnapshot();
EXPECT_EQ(inputDebug.lastShortcutCommandId, "window.command");
EXPECT_EQ(inputDebug.lastShortcutScope, "Window");
EXPECT_TRUE(inputDebug.lastShortcutHandled);
EXPECT_NE(inputDebug.focusedStateKey.find("/window-focus"), std::string::npos);
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(globalRect));
SendCtrlP(player, initialInput.viewportRect, frameIndex);
inputDebug = host.GetInputDebugSnapshot();
EXPECT_EQ(inputDebug.lastShortcutCommandId, "global.command");
EXPECT_EQ(inputDebug.lastShortcutScope, "Global");
EXPECT_TRUE(inputDebug.lastShortcutHandled);
EXPECT_NE(inputDebug.focusedStateKey.find("/global-focus"), std::string::npos);
}
TEST(UIRuntimeShortcutScopeTest, DocumentHostSuppresssShortcutAndTabTraversalWhenTextInputScopeIsFocused) {
TempFileScope viewFile(
"xcui_runtime_shortcut_suppression",
".xcui",
"<View name=\"Shortcut Suppression Test\" shortcut=\"Ctrl+P\" shortcutCommand=\"global.command\" shortcutScope=\"global\">\n"
" <Column padding=\"18\" gap=\"12\">\n"
" <Card id=\"panel-shell\" title=\"Panel Scope\" shortcutScopeRoot=\"panel\" shortcut=\"Ctrl+P\" shortcutCommand=\"panel.command\" shortcutScope=\"panel\">\n"
" <Column gap=\"10\">\n"
" <Button id=\"text-input\" text=\"Text Input Proxy\" textInput=\"true\" />\n"
" <Button id=\"after-text\" text=\"After Text\" />\n"
" </Column>\n"
" </Card>\n"
" </Column>\n"
"</View>\n");
UIDocumentScreenHost host = {};
UIScreenPlayer player(host);
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.shortcut.suppression")));
UIScreenFrameInput initialInput = BuildInputState(1u);
initialInput.viewportRect = UIRect(0.0f, 0.0f, 900.0f, 520.0f);
const auto& initialFrame = player.Update(initialInput);
UIRect textInputRect = {};
ASSERT_TRUE(TryFindFilledRectForText(initialFrame.drawData, "Text Input Proxy", textInputRect));
std::uint64_t frameIndex = 2u;
FocusButton(player, initialInput.viewportRect, frameIndex, GetRectCenter(textInputRect));
SendCtrlP(player, initialInput.viewportRect, frameIndex);
auto inputDebug = host.GetInputDebugSnapshot();
EXPECT_TRUE(inputDebug.textInputActive);
EXPECT_EQ(inputDebug.lastShortcutCommandId, "panel.command");
EXPECT_EQ(inputDebug.lastShortcutScope, "Panel");
EXPECT_FALSE(inputDebug.lastShortcutHandled);
EXPECT_TRUE(inputDebug.lastShortcutSuppressed);
EXPECT_EQ(inputDebug.lastResult, "Shortcut suppressed by text input");
EXPECT_NE(inputDebug.focusedStateKey.find("/text-input"), std::string::npos);
EXPECT_NE(inputDebug.panelScopeStateKey.find("/panel-shell"), std::string::npos);
SendTab(player, initialInput.viewportRect, frameIndex);
inputDebug = host.GetInputDebugSnapshot();
EXPECT_TRUE(inputDebug.textInputActive);
EXPECT_EQ(inputDebug.lastResult, "Focus traversal suppressed by text input");
EXPECT_NE(inputDebug.focusedStateKey.find("/text-input"), std::string::npos);
}

View File

@@ -0,0 +1,82 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <string>
namespace {
using XCEngine::UI::UIRect;
using XCEngine::UI::Runtime::UIScreenAsset;
using XCEngine::UI::Runtime::UIScreenFrameInput;
using XCEngine::UI::Runtime::UIScreenPlayer;
using XCEngine::UI::Runtime::UIDocumentScreenHost;
namespace fs = std::filesystem;
class TempFileScope {
public:
TempFileScope(std::string stem, std::string extension, std::string contents) {
const auto uniqueId = std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count());
m_path = fs::temp_directory_path() / (std::move(stem) + "_" + uniqueId + std::move(extension));
std::ofstream output(m_path, std::ios::binary | std::ios::trunc);
output << contents;
}
~TempFileScope() {
std::error_code ec;
fs::remove(m_path, ec);
}
const fs::path& Path() const {
return m_path;
}
private:
fs::path m_path = {};
};
UIScreenAsset BuildScreenAsset(const fs::path& viewPath, const char* screenId) {
UIScreenAsset screen = {};
screen.screenId = screenId;
screen.documentPath = viewPath.string();
return screen;
}
UIScreenFrameInput BuildInputState(std::uint64_t frameIndex = 1u) {
UIScreenFrameInput input = {};
input.viewportRect = UIRect(0.0f, 0.0f, 960.0f, 720.0f);
input.frameIndex = frameIndex;
input.focused = true;
return input;
}
} // namespace
TEST(UIRuntimeSplitterValidationTest, InvalidSplitterArityProducesExplicitFrameError) {
TempFileScope viewFile(
"xcui_runtime_invalid_splitter",
".xcui",
"<View name=\"Invalid Splitter Test\">\n"
" <Column padding=\"16\" gap=\"10\">\n"
" <Splitter id=\"broken-splitter\" axis=\"horizontal\" splitRatio=\"0.5\">\n"
" <Card title=\"Only Child\" />\n"
" </Splitter>\n"
" </Column>\n"
"</View>\n");
UIDocumentScreenHost host = {};
UIScreenPlayer player(host);
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.invalid_splitter")));
const auto& frame = player.Update(BuildInputState());
EXPECT_FALSE(frame.errorMessage.empty());
EXPECT_NE(frame.errorMessage.find("broken-splitter"), std::string::npos);
EXPECT_NE(frame.errorMessage.find("exactly 2 child elements"), std::string::npos);
}

165
tests/UI/TEST_SPEC.md Normal file
View File

@@ -0,0 +1,165 @@
# XCUI TEST_SPEC
日期: `2026-04-06`
## 1. 目标
本规范只约束 XCUI 模块自身的测试体系,不负责复刻完整 editor 产品。
XCUI 测试体系固定为两条并行通道:
1. `unit`
2. `integration`
两者必须同时存在,但职责严格分离:
- `unit` 负责规则、状态机、路由与回归稳定性,默认可自动执行。
- `integration` 负责生成可操作 exe让人直接检查交互、布局、焦点、shortcut、滚动与视觉状态。
## 2. 顶层目录
XCUI 的测试树统一放在:
```text
tests/UI/
TEST_SPEC.md
Core/
unit/
integration/
Runtime/
unit/
integration/
Editor/
unit/
integration/
```
三层必须明确分开:
- `Core`
- 共享 UI 基础层测试。
- 例如 tree state、layout、focus、input router、shortcut、scroll、text controller。
- `Runtime`
- 面向游戏运行时 UI 的测试。
- 例如 screen stack、layer blocking、runtime 输入路由、runtime-only widget。
- `Editor`
- 面向编辑器 UI 的测试。
- 例如 editor 输入宿主、editor shell 验证场景、editor-only widget。
禁止把 `Runtime``Editor` 混在同一个测试目标里。
## 3. Unit 规范
`unit` 测试要求:
- 直接面向底层能力。
- 不依赖人工观察。
- 默认进入自动化回归。
- 新增一块共享能力时,优先先补 `unit`
`unit` 测试不负责:
- 人工手感检查。
- 布局观感检查。
- 完整交互场景展示。
## 4. Integration 规范
`integration` 测试要求:
- 必须产出可直接运行的 exe。
- 一个 exe 只验证一个聚焦场景。
- 每次只暴露当前批次需要检查的操作区域,不做大杂烩面板。
- 界面中的操作提示默认使用中文,必要时可混用 `hover``focus``active``capture` 等术语。
`integration` 测试不负责:
- 模拟完整 editor 产品外壳。
- 把多个无关能力塞进同一个窗口。
- 代替底层 `unit` 回归。
## 5. Scenario 目录规范
### 5.1 Editor
```text
tests/UI/Editor/integration/
shared/
src/
<category>/
<scenario>/
CMakeLists.txt
main.cpp
View.xcui
captures/
```
约束:
- `shared/` 只放宿主、渲染、截图、scenario registry 等共用基础设施。
- `<category>` 表示能力类别,例如 `input`
- `<scenario>` 是最小验证单元。
- 一个 `<scenario>` 对应一个 exe。
### 5.2 Runtime
```text
tests/UI/Runtime/integration/
<category>/
<scenario>/
CMakeLists.txt
main.cpp
View.xcui
captures/
```
Runtime 的集成测试结构与 Editor 保持同一规范,但宿主职责必须与 Editor 分离。
## 6. 当前已有 Editor 场景
- `editor.input.keyboard_focus`
- `editor.input.pointer_states`
- `editor.input.shortcut_scope`
- `editor.layout.splitter_resize`
这些场景只用于验证 XCUI 模块能力,不代表开始复刻完整 editor 面板。
## 7. 截图规范
Editor 集成宿主支持:
- `F12` 手动截图。
- 截图只允许截当前 exe 自己的渲染结果。
- 截图输出到当前 scenario 自己的 `captures/` 目录。
输出格式:
- `captures/latest.png`
- `captures/history/<timestamp>_<index>_<reason>.png`
原则:
- 不做持续高频自动截图轰炸。
- 只在人工检查、问题复现、调试定位时触发截图。
## 8. 开发顺序
XCUI 必须坚持自底向上的建设顺序:
1. 先补共享底层能力。
2. 先补对应 `unit`
3. 再补一个聚焦的 `integration` exe。
4. 人工检查通过后再继续向上推进。
禁止事项:
- 先堆 editor 具体面板,再回头补底层。
-`new_editor` 当作 XCUI 主测试入口。
- 把一个验证 exe 做成综合试验场。
- 为了赶进度写跨层耦合的临时代码。
## 9. 当前入口约定
当前 XCUI 的正式验证入口是 `tests/UI`
`new_editor` 不是后续 XCUI 测试体系的主入口,也不应继续承载新的测试场景扩展。

View File

@@ -28,10 +28,6 @@ add_subdirectory(Containers)
# ============================================================ # ============================================================
add_subdirectory(Math) add_subdirectory(Math)
# ============================================================
# Core/UI Tests
# ============================================================
add_subdirectory(UI)
add_subdirectory(UIStyle) add_subdirectory(UIStyle)
# Exclude all static runtime libraries to avoid conflicts # Exclude all static runtime libraries to avoid conflicts