feat(xcui): advance core and editor validation flow

This commit is contained in:
2026-04-06 16:20:46 +08:00
parent 33bb84f650
commit 2d030a97da
128 changed files with 9961 additions and 773 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
#pragma once #pragma once
#include <XCEngine/Resources/UI/UIDocumentTypes.h> #include <XCEngine/Resources/UI/UIDocumentTypes.h>
#include <XCEngine/UI/Style/Theme.h>
#include <XCEngine/UI/Style/StyleSet.h>
#include <XCEngine/UI/DrawData.h> #include <XCEngine/UI/DrawData.h>
#include <cstddef> #include <cstddef>
@@ -30,7 +32,10 @@ struct UIScreenDocument {
std::vector<std::string> dependencies = {}; std::vector<std::string> dependencies = {};
Resources::UIDocumentModel viewDocument = {}; Resources::UIDocumentModel viewDocument = {};
Resources::UIDocumentModel themeDocument = {}; Resources::UIDocumentModel themeDocument = {};
Style::UITheme runtimeTheme = {};
Style::UIStyleSheet runtimeStyleSheet = {};
bool hasThemeDocument = false; bool hasThemeDocument = false;
bool hasRuntimeTheme = false;
bool IsValid() const { bool IsValid() const {
return !sourcePath.empty(); return !sourcePath.empty();
@@ -39,6 +44,14 @@ struct UIScreenDocument {
const Resources::UIDocumentModel* GetThemeDocument() const { const Resources::UIDocumentModel* GetThemeDocument() const {
return hasThemeDocument ? &themeDocument : nullptr; return hasThemeDocument ? &themeDocument : nullptr;
} }
const Style::UITheme* GetRuntimeTheme() const {
return hasRuntimeTheme ? &runtimeTheme : nullptr;
}
const Style::UIStyleSheet* GetRuntimeStyleSheet() const {
return hasRuntimeTheme ? &runtimeStyleSheet : nullptr;
}
}; };
struct UIScreenLoadResult { struct UIScreenLoadResult {

View File

@@ -0,0 +1,33 @@
#pragma once
#include "StyleResolver.h"
#include <XCEngine/Resources/UI/UIDocumentTypes.h>
#include <string>
namespace XCEngine {
namespace UI {
namespace Style {
struct UIDocumentStyleCompileResult {
bool succeeded = false;
std::string errorMessage = {};
UITheme theme = {};
UIStyleSheet styleSheet = {};
};
UIDocumentStyleCompileResult CompileDocumentStyle(
const Resources::UIDocumentModel& themeDocument);
bool TryCompileDocumentStyle(
const Resources::UIDocumentModel& themeDocument,
UITheme& outTheme,
UIStyleSheet& outStyleSheet,
std::string* outErrorMessage = nullptr);
UIStyleSet BuildInlineStyle(const Resources::UIDocumentNode& node);
} // namespace Style
} // namespace UI
} // namespace XCEngine

View File

@@ -6,6 +6,7 @@
#include <XCEngine/Resources/UI/UIDocumentCompiler.h> #include <XCEngine/Resources/UI/UIDocumentCompiler.h>
#include <XCEngine/UI/Layout/LayoutEngine.h> #include <XCEngine/UI/Layout/LayoutEngine.h>
#include <XCEngine/UI/Layout/UITabStripLayout.h> #include <XCEngine/UI/Layout/UITabStripLayout.h>
#include <XCEngine/UI/Style/DocumentStyleCompiler.h>
#include <XCEngine/UI/Widgets/UITabStripModel.h> #include <XCEngine/UI/Widgets/UITabStripModel.h>
#include <algorithm> #include <algorithm>
@@ -34,6 +35,7 @@ using XCEngine::Resources::UIDocumentCompileResult;
using XCEngine::Resources::UIDocumentKind; using XCEngine::Resources::UIDocumentKind;
using XCEngine::Resources::UIDocumentNode; using XCEngine::Resources::UIDocumentNode;
namespace Layout = XCEngine::UI::Layout; namespace Layout = XCEngine::UI::Layout;
namespace Style = XCEngine::UI::Style;
constexpr float kDefaultFontSize = 16.0f; constexpr float kDefaultFontSize = 16.0f;
constexpr float kSmallFontSize = 13.0f; constexpr float kSmallFontSize = 13.0f;
@@ -78,6 +80,8 @@ struct RuntimeLayoutNode {
bool tabSelected = false; bool tabSelected = false;
bool hasShortcutBinding = false; bool hasShortcutBinding = false;
UIShortcutBinding shortcutBinding = {}; UIShortcutBinding shortcutBinding = {};
Style::UIStyleSet localStyle = {};
Style::UIResolvedStyle resolvedStyle = {};
enum class ShortcutScopeRoot : std::uint8_t { enum class ShortcutScopeRoot : std::uint8_t {
None = 0, None = 0,
Window, Window,
@@ -580,6 +584,166 @@ Layout::UILayoutThickness ParsePadding(
return Layout::UILayoutThickness::Uniform(ParseFloatAttribute(node, "padding", fallback)); return Layout::UILayoutThickness::Uniform(ParseFloatAttribute(node, "padding", fallback));
} }
Layout::UILayoutThickness ToLayoutThickness(const Style::UIThickness& thickness) {
return Layout::UILayoutThickness(
thickness.left,
thickness.top,
thickness.right,
thickness.bottom);
}
const Style::UIStylePropertyResolution* FindResolvedProperty(
const RuntimeLayoutNode& node,
Style::UIStylePropertyId propertyId) {
return node.resolvedStyle.FindProperty(propertyId);
}
float ResolveNodeFontSize(
const RuntimeLayoutNode& node,
float fallback) {
const Style::UIStylePropertyResolution* resolution =
FindResolvedProperty(node, Style::UIStylePropertyId::FontSize);
if (resolution == nullptr) {
return fallback;
}
if (const float* value = resolution->value.TryGetFloat()) {
return (std::max)(1.0f, *value);
}
return fallback;
}
float ResolveNodeGap(
const RuntimeLayoutNode& node,
float fallback) {
const Style::UIStylePropertyResolution* resolution =
FindResolvedProperty(node, Style::UIStylePropertyId::Gap);
if (resolution == nullptr) {
return fallback;
}
if (const float* value = resolution->value.TryGetFloat()) {
return (std::max)(0.0f, *value);
}
return fallback;
}
Layout::UILayoutThickness ResolveNodePadding(
const RuntimeLayoutNode& node,
float fallback) {
const Style::UIStylePropertyResolution* resolution =
FindResolvedProperty(node, Style::UIStylePropertyId::Padding);
if (resolution == nullptr) {
return Layout::UILayoutThickness::Uniform(fallback);
}
if (const Style::UIThickness* thickness = resolution->value.TryGetThickness()) {
return ToLayoutThickness(*thickness);
}
return Layout::UILayoutThickness::Uniform(fallback);
}
float ResolveNodeBorderWidth(
const RuntimeLayoutNode& node,
float fallback) {
const Style::UIStylePropertyResolution* resolution =
FindResolvedProperty(node, Style::UIStylePropertyId::BorderWidth);
if (resolution == nullptr) {
return fallback;
}
if (const float* value = resolution->value.TryGetFloat()) {
return (std::max)(0.0f, *value);
}
return fallback;
}
float ResolveNodeCornerRadius(
const RuntimeLayoutNode& node,
float fallback) {
const Style::UIStylePropertyResolution* resolution =
FindResolvedProperty(node, Style::UIStylePropertyId::CornerRadius);
if (resolution == nullptr) {
return fallback;
}
if (const Style::UICornerRadius* radius = resolution->value.TryGetCornerRadius()) {
return (std::max)({
0.0f,
radius->topLeft,
radius->topRight,
radius->bottomRight,
radius->bottomLeft
});
}
return fallback;
}
Color AdjustColorBrightness(const Color& color, float delta) {
auto clampChannel = [delta](float value) {
return (std::clamp)(value + delta, 0.0f, 1.0f);
};
return Color(
clampChannel(color.r),
clampChannel(color.g),
clampChannel(color.b),
color.a);
}
Color ResolveLegacyBaseBackgroundColor(const UIDocumentNode& node) {
const std::string tone = GetAttribute(node, "tone");
const std::string tagName = ToStdString(node.tagName);
if (tagName == "View") {
return Color(0.11f, 0.11f, 0.11f, 1.0f);
}
if (tone == "accent") {
return Color(0.25f, 0.25f, 0.25f, 1.0f);
}
if (tone == "accent-alt") {
return Color(0.22f, 0.22f, 0.22f, 1.0f);
}
if (tagName == "Button") {
return Color(0.24f, 0.24f, 0.24f, 1.0f);
}
return Color(0.16f, 0.16f, 0.16f, 1.0f);
}
Color ResolveLegacyBaseBorderColor(const UIDocumentNode& node) {
const std::string tone = GetAttribute(node, "tone");
if (tone == "accent") {
return Color(0.42f, 0.42f, 0.42f, 1.0f);
}
if (tone == "accent-alt") {
return Color(0.34f, 0.34f, 0.34f, 1.0f);
}
return Color(0.30f, 0.30f, 0.30f, 1.0f);
}
Color ResolveStyledColor(
const RuntimeLayoutNode& node,
Style::UIStylePropertyId propertyId,
const Color& fallback) {
const Style::UIStylePropertyResolution* resolution = FindResolvedProperty(node, propertyId);
if (resolution == nullptr) {
return fallback;
}
if (const Color* color = resolution->value.TryGetColor()) {
return *color;
}
return fallback;
}
Layout::UILayoutItem BuildLayoutItem( Layout::UILayoutItem BuildLayoutItem(
const RuntimeLayoutNode& child, const RuntimeLayoutNode& child,
Layout::UILayoutAxis parentAxis, Layout::UILayoutAxis parentAxis,
@@ -1283,61 +1447,68 @@ UIInputPath ResolveHoveredPath(
} }
Color ResolveBackgroundColor( Color ResolveBackgroundColor(
const UIDocumentNode& node, const RuntimeLayoutNode& node,
const RuntimeNodeVisualState& state) { const RuntimeNodeVisualState& state) {
const std::string tone = GetAttribute(node, "tone"); const std::string tagName = ToStdString(node.source->tagName);
const std::string tagName = ToStdString(node.tagName); const Color baseColor = ResolveStyledColor(
node,
if (tagName == "View") { Style::UIStylePropertyId::BackgroundColor,
return Color(0.11f, 0.11f, 0.11f, 1.0f); ResolveLegacyBaseBackgroundColor(*node.source));
}
if (tone == "accent") {
return Color(0.25f, 0.25f, 0.25f, 1.0f);
}
if (tone == "accent-alt") {
return Color(0.22f, 0.22f, 0.22f, 1.0f);
}
if (tagName == "Button") { if (tagName == "Button") {
if (state.active || state.capture) { if (state.active || state.capture) {
return Color(0.30f, 0.30f, 0.30f, 1.0f); return AdjustColorBrightness(baseColor, 0.06f);
} }
if (state.hovered) { if (state.hovered) {
return Color(0.27f, 0.27f, 0.27f, 1.0f); return AdjustColorBrightness(baseColor, 0.03f);
} }
return Color(0.24f, 0.24f, 0.24f, 1.0f);
} }
return Color(0.16f, 0.16f, 0.16f, 1.0f); return baseColor;
} }
Color ResolveBorderColor( Color ResolveBorderColor(
const UIDocumentNode& node, const RuntimeLayoutNode& node,
const RuntimeNodeVisualState& state) { const RuntimeNodeVisualState& state) {
const Color baseColor = ResolveStyledColor(
node,
Style::UIStylePropertyId::BorderColor,
ResolveLegacyBaseBorderColor(*node.source));
if (state.capture) { if (state.capture) {
return Color(0.82f, 0.82f, 0.82f, 1.0f); return AdjustColorBrightness(baseColor, 0.40f);
} }
if (state.focused || state.active) { if (state.focused || state.active) {
return Color(0.62f, 0.62f, 0.62f, 1.0f); return AdjustColorBrightness(baseColor, 0.24f);
} }
if (state.hovered) { if (state.hovered) {
return Color(0.45f, 0.45f, 0.45f, 1.0f); return AdjustColorBrightness(baseColor, 0.12f);
} }
const std::string tone = GetAttribute(node, "tone"); return baseColor;
if (tone == "accent") {
return Color(0.42f, 0.42f, 0.42f, 1.0f);
}
if (tone == "accent-alt") {
return Color(0.34f, 0.34f, 0.34f, 1.0f);
}
return Color(0.30f, 0.30f, 0.30f, 1.0f);
} }
float ResolveBorderThickness(const RuntimeNodeVisualState& state) { float ResolveBorderThickness(
return (state.focused || state.active || state.capture) ? 2.0f : 1.0f; const RuntimeLayoutNode& node,
const RuntimeNodeVisualState& state) {
const float baseThickness = ResolveNodeBorderWidth(node, 1.0f);
if (state.focused || state.active || state.capture) {
return (std::max)(baseThickness, 2.0f);
}
return baseThickness;
}
Color ResolveForegroundColor(
const RuntimeLayoutNode& node,
const RuntimeNodeVisualState& state,
const Color& fallback) {
Color color = ResolveStyledColor(node, Style::UIStylePropertyId::ForegroundColor, fallback);
if (ToStdString(node.source->tagName) == "Button" &&
(state.capture || state.focused || state.active)) {
color = AdjustColorBrightness(color, 0.08f);
}
return color;
} }
bool IsPathTarget( bool IsPathTarget(
@@ -1360,6 +1531,7 @@ RuntimeNodeVisualState ResolveNodeVisualState(
RuntimeLayoutNode BuildLayoutTree( RuntimeLayoutNode BuildLayoutTree(
const UIDocumentNode& source, const UIDocumentNode& source,
const UIScreenDocument& document,
const std::string& parentStateKey, const std::string& parentStateKey,
const UIInputPath& parentInputPath, const UIInputPath& parentInputPath,
std::size_t siblingIndex, std::size_t siblingIndex,
@@ -1388,10 +1560,19 @@ RuntimeLayoutNode BuildLayoutTree(
ParseRatioAttribute(source, "ratio", 0.5f)); ParseRatioAttribute(source, "ratio", 0.5f));
node.shortcutScopeRoot = ParseShortcutScopeRoot(source); node.shortcutScopeRoot = ParseShortcutScopeRoot(source);
node.hasShortcutBinding = TryBuildShortcutBinding(source, node.elementId, node.shortcutBinding); node.hasShortcutBinding = TryBuildShortcutBinding(source, node.elementId, node.shortcutBinding);
node.localStyle = Style::BuildInlineStyle(source);
Style::UIStyleResolveContext styleContext = {};
styleContext.theme = document.GetRuntimeTheme();
styleContext.styleSheet = document.GetRuntimeStyleSheet();
styleContext.selector.typeName = tagName;
styleContext.selector.styleName = GetAttribute(source, "style");
styleContext.localStyle = &node.localStyle;
node.resolvedStyle = Style::ResolveStyle(styleContext);
node.children.reserve(source.children.Size()); node.children.reserve(source.children.Size());
for (std::size_t index = 0; index < source.children.Size(); ++index) { for (std::size_t index = 0; index < source.children.Size(); ++index) {
node.children.push_back(BuildLayoutTree( node.children.push_back(BuildLayoutTree(
source.children[index], source.children[index],
document,
node.stateKey, node.stateKey,
node.inputPath, node.inputPath,
index, index,
@@ -1417,17 +1598,22 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
if (tagName == "Text") { if (tagName == "Text") {
const std::string text = ResolveNodeText(source); const std::string text = ResolveNodeText(source);
const float fontSize = ResolveNodeFontSize(node, kDefaultFontSize);
node.desiredSize = UISize( node.desiredSize = UISize(
MeasureTextWidth(text, kDefaultFontSize), MeasureTextWidth(text, fontSize),
MeasureTextHeight(kDefaultFontSize)); MeasureTextHeight(fontSize));
node.minimumSize = node.desiredSize; node.minimumSize = node.desiredSize;
return node.desiredSize; return node.desiredSize;
} }
if (!IsContainerTag(source)) { if (!IsContainerTag(source)) {
const float fontSize = ResolveNodeFontSize(
node,
tagName == "Button" ? kButtonFontSize : kDefaultFontSize);
const Layout::UILayoutThickness padding = ResolveNodePadding(node, 12.0f);
node.desiredSize = UISize( node.desiredSize = UISize(
(std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + 24.0f), (std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), fontSize) + padding.Horizontal()),
44.0f); (std::max)(44.0f, MeasureTextHeight(fontSize) + padding.Vertical()));
node.minimumSize = node.desiredSize; node.minimumSize = node.desiredSize;
return node.desiredSize; return node.desiredSize;
} }
@@ -1532,12 +1718,11 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
options.axis = IsHorizontalTag(tagName) options.axis = IsHorizontalTag(tagName)
? Layout::UILayoutAxis::Horizontal ? Layout::UILayoutAxis::Horizontal
: Layout::UILayoutAxis::Vertical; : Layout::UILayoutAxis::Vertical;
options.spacing = ParseFloatAttribute( options.spacing = ResolveNodeGap(
source, node,
"gap",
options.axis == Layout::UILayoutAxis::Horizontal ? 10.0f : 8.0f); options.axis == Layout::UILayoutAxis::Horizontal ? 10.0f : 8.0f);
options.padding = ParsePadding( options.padding = ResolveNodePadding(
source, node,
tagName == "View" ? 16.0f : 12.0f); tagName == "View" ? 16.0f : 12.0f);
std::vector<Layout::UILayoutItem> desiredItems = {}; std::vector<Layout::UILayoutItem> desiredItems = {};
@@ -1676,12 +1861,11 @@ void ArrangeNode(
options.axis = IsHorizontalTag(tagName) options.axis = IsHorizontalTag(tagName)
? Layout::UILayoutAxis::Horizontal ? Layout::UILayoutAxis::Horizontal
: Layout::UILayoutAxis::Vertical; : Layout::UILayoutAxis::Vertical;
options.spacing = ParseFloatAttribute( options.spacing = ResolveNodeGap(
source, node,
"gap",
options.axis == Layout::UILayoutAxis::Horizontal ? 10.0f : 8.0f); options.axis == Layout::UILayoutAxis::Horizontal ? 10.0f : 8.0f);
options.padding = ParsePadding( options.padding = ResolveNodePadding(
source, node,
tagName == "View" ? 16.0f : 12.0f); tagName == "View" ? 16.0f : 12.0f);
const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source); const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source);
@@ -2414,15 +2598,16 @@ void EmitNode(
++stats.nodeCount; ++stats.nodeCount;
if (tagName == "View" || tagName == "Card" || tagName == "Button") { if (tagName == "View" || tagName == "Card" || tagName == "Button") {
drawList.AddFilledRect(node.rect, ToUIColor(ResolveBackgroundColor(source, visualState)), 10.0f); const float cornerRadius = ResolveNodeCornerRadius(node, 10.0f);
drawList.AddFilledRect(node.rect, ToUIColor(ResolveBackgroundColor(node, visualState)), cornerRadius);
++stats.filledRectCommandCount; ++stats.filledRectCommandCount;
if (tagName != "View") { if (tagName != "View") {
drawList.AddRectOutline( drawList.AddRectOutline(
node.rect, node.rect,
ToUIColor(ResolveBorderColor(source, visualState)), ToUIColor(ResolveBorderColor(node, visualState)),
ResolveBorderThickness(visualState), ResolveBorderThickness(node, visualState),
10.0f); cornerRadius);
} }
} }
@@ -2539,22 +2724,27 @@ void EmitNode(
} }
if (tagName == "Text") { if (tagName == "Text") {
const float fontSize = ResolveNodeFontSize(node, kDefaultFontSize);
drawList.AddText( drawList.AddText(
UIPoint(node.rect.x, node.rect.y), UIPoint(node.rect.x, node.rect.y),
ResolveNodeText(source), ResolveNodeText(source),
ToUIColor(Color(0.92f, 0.94f, 0.97f, 1.0f)), ToUIColor(ResolveForegroundColor(node, visualState, Color(0.92f, 0.94f, 0.97f, 1.0f))),
kDefaultFontSize); fontSize);
++stats.textCommandCount; ++stats.textCommandCount;
} }
if (tagName == "Button" && title.empty() && subtitle.empty()) { if (tagName == "Button" && title.empty() && subtitle.empty()) {
const float fontSize = ResolveNodeFontSize(node, kButtonFontSize);
drawList.AddText( drawList.AddText(
UIPoint(node.rect.x + 12.0f, ComputeCenteredTextTop(node.rect, kButtonFontSize)), UIPoint(node.rect.x + 12.0f, ComputeCenteredTextTop(node.rect, fontSize)),
ResolveNodeText(source), ResolveNodeText(source),
ToUIColor(visualState.capture || visualState.focused ToUIColor(ResolveForegroundColor(
? Color(1.0f, 1.0f, 1.0f, 1.0f) node,
: Color(0.95f, 0.97f, 1.0f, 1.0f)), visualState,
kButtonFontSize); visualState.capture || visualState.focused
? Color(1.0f, 1.0f, 1.0f, 1.0f)
: Color(0.95f, 0.97f, 1.0f, 1.0f))),
fontSize);
++stats.textCommandCount; ++stats.textCommandCount;
} }
@@ -2667,6 +2857,19 @@ UIScreenLoadResult UIDocumentScreenHost::LoadScreen(const UIScreenAsset& asset)
result.document.themeDocument = themeResult.document; result.document.themeDocument = themeResult.document;
result.document.hasThemeDocument = true; result.document.hasThemeDocument = true;
std::string runtimeThemeError = {};
if (!Style::TryCompileDocumentStyle(
result.document.themeDocument,
result.document.runtimeTheme,
result.document.runtimeStyleSheet,
&runtimeThemeError)) {
result = {};
result.errorMessage = runtimeThemeError.empty()
? "Failed to compile runtime UI theme styles."
: runtimeThemeError;
return result;
}
result.document.hasRuntimeTheme = true;
if (seenDependencies.insert(asset.themePath).second) { if (seenDependencies.insert(asset.themePath).second) {
result.document.dependencies.push_back(asset.themePath); result.document.dependencies.push_back(asset.themePath);
} }
@@ -2693,6 +2896,7 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
: document.sourcePath; : document.sourcePath;
RuntimeLayoutNode root = BuildLayoutTree( RuntimeLayoutNode root = BuildLayoutTree(
document.viewDocument.rootNode, document.viewDocument.rootNode,
document,
stateRoot, stateRoot,
UIInputPath(), UIInputPath(),
0u, 0u,

View File

@@ -0,0 +1,598 @@
#include <XCEngine/UI/Style/DocumentStyleCompiler.h>
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine {
namespace UI {
namespace Style {
namespace {
using XCEngine::Math::Color;
using XCEngine::Resources::UIDocumentAttribute;
using XCEngine::Resources::UIDocumentModel;
using XCEngine::Resources::UIDocumentNode;
std::string ToStdString(const Containers::String& value) {
return value.Empty() || value.CStr() == nullptr
? std::string()
: std::string(value.CStr());
}
std::string TrimAscii(std::string value) {
std::size_t start = 0u;
while (start < value.size() &&
std::isspace(static_cast<unsigned char>(value[start])) != 0) {
++start;
}
std::size_t end = value.size();
while (end > start &&
std::isspace(static_cast<unsigned char>(value[end - 1u])) != 0) {
--end;
}
return value.substr(start, end - start);
}
std::string ToLowerAscii(std::string value) {
std::transform(
value.begin(),
value.end(),
value.begin(),
[](unsigned char ch) {
return static_cast<char>(std::tolower(ch));
});
return value;
}
const UIDocumentAttribute* FindAttribute(const UIDocumentNode& node, const char* name) {
for (const UIDocumentAttribute& attribute : node.attributes) {
if (attribute.name == name) {
return &attribute;
}
}
return nullptr;
}
std::string GetAttribute(
const UIDocumentNode& node,
const char* name,
const std::string& fallback = {}) {
const UIDocumentAttribute* attribute = FindAttribute(node, name);
return attribute != nullptr ? ToStdString(attribute->value) : fallback;
}
bool TryParseFloat(const std::string& text, float& outValue) {
const std::string trimmed = TrimAscii(text);
if (trimmed.empty()) {
return false;
}
char* end = nullptr;
const float value = std::strtof(trimmed.c_str(), &end);
if (end == trimmed.c_str()) {
return false;
}
while (*end != '\0') {
if (std::isspace(static_cast<unsigned char>(*end)) == 0) {
return false;
}
++end;
}
outValue = value;
return true;
}
int HexToInt(char ch) {
if (ch >= '0' && ch <= '9') {
return ch - '0';
}
if (ch >= 'a' && ch <= 'f') {
return 10 + (ch - 'a');
}
if (ch >= 'A' && ch <= 'F') {
return 10 + (ch - 'A');
}
return -1;
}
bool TryParseHexByte(std::string_view text, std::uint8_t& outValue) {
if (text.size() != 2u) {
return false;
}
const int high = HexToInt(text[0]);
const int low = HexToInt(text[1]);
if (high < 0 || low < 0) {
return false;
}
outValue = static_cast<std::uint8_t>((high << 4) | low);
return true;
}
bool TryParseColorValue(const std::string& text, Color& outColor) {
const std::string trimmed = TrimAscii(text);
if (trimmed.size() != 7u && trimmed.size() != 9u) {
return false;
}
if (trimmed.front() != '#') {
return false;
}
std::uint8_t r = 0u;
std::uint8_t g = 0u;
std::uint8_t b = 0u;
std::uint8_t a = 255u;
if (!TryParseHexByte(std::string_view(trimmed).substr(1u, 2u), r) ||
!TryParseHexByte(std::string_view(trimmed).substr(3u, 2u), g) ||
!TryParseHexByte(std::string_view(trimmed).substr(5u, 2u), b)) {
return false;
}
if (trimmed.size() == 9u &&
!TryParseHexByte(std::string_view(trimmed).substr(7u, 2u), a)) {
return false;
}
constexpr float kInv255 = 1.0f / 255.0f;
outColor = Color(
static_cast<float>(r) * kInv255,
static_cast<float>(g) * kInv255,
static_cast<float>(b) * kInv255,
static_cast<float>(a) * kInv255);
return true;
}
bool TryParseFloatList(const std::string& text, std::vector<float>& outValues) {
outValues.clear();
std::string token = {};
auto flushToken = [&]() {
if (token.empty()) {
return true;
}
float value = 0.0f;
if (!TryParseFloat(token, value)) {
return false;
}
outValues.push_back(value);
token.clear();
return true;
};
for (char ch : text) {
if (std::isspace(static_cast<unsigned char>(ch)) != 0 ||
ch == ',' ||
ch == ';') {
if (!flushToken()) {
return false;
}
continue;
}
token.push_back(ch);
}
return flushToken() && !outValues.empty();
}
bool TryParseThicknessValue(const std::string& text, UIThickness& outThickness) {
std::vector<float> values = {};
if (!TryParseFloatList(text, values)) {
return false;
}
if (values.size() == 1u) {
outThickness = UIThickness::Uniform(values[0]);
return true;
}
if (values.size() == 2u) {
const float vertical = values[0];
const float horizontal = values[1];
outThickness = UIThickness{ horizontal, vertical, horizontal, vertical };
return true;
}
if (values.size() == 4u) {
const float top = values[0];
const float right = values[1];
const float bottom = values[2];
const float left = values[3];
outThickness = UIThickness{ left, top, right, bottom };
return true;
}
return false;
}
bool TryParseCornerRadiusValue(const std::string& text, UICornerRadius& outRadius) {
std::vector<float> values = {};
if (!TryParseFloatList(text, values)) {
return false;
}
if (values.size() == 1u) {
outRadius = UICornerRadius::Uniform(values[0]);
return true;
}
if (values.size() == 2u) {
outRadius = UICornerRadius{ values[0], values[1], values[0], values[1] };
return true;
}
if (values.size() == 4u) {
outRadius = UICornerRadius{ values[0], values[1], values[2], values[3] };
return true;
}
return false;
}
bool TryMapPropertyName(const std::string& name, UIStylePropertyId& outPropertyId) {
const std::string normalized = ToLowerAscii(TrimAscii(name));
if (normalized == "background" || normalized == "backgroundcolor") {
outPropertyId = UIStylePropertyId::BackgroundColor;
return true;
}
if (normalized == "foreground" || normalized == "foregroundcolor" || normalized == "textcolor") {
outPropertyId = UIStylePropertyId::ForegroundColor;
return true;
}
if (normalized == "bordercolor") {
outPropertyId = UIStylePropertyId::BorderColor;
return true;
}
if (normalized == "borderwidth") {
outPropertyId = UIStylePropertyId::BorderWidth;
return true;
}
if (normalized == "radius" || normalized == "cornerradius") {
outPropertyId = UIStylePropertyId::CornerRadius;
return true;
}
if (normalized == "padding") {
outPropertyId = UIStylePropertyId::Padding;
return true;
}
if (normalized == "gap" || normalized == "spacing") {
outPropertyId = UIStylePropertyId::Gap;
return true;
}
if (normalized == "fontsize") {
outPropertyId = UIStylePropertyId::FontSize;
return true;
}
if (normalized == "linewidth") {
outPropertyId = UIStylePropertyId::LineWidth;
return true;
}
return false;
}
bool TryParseTokenReferenceValue(const std::string& text, UIStyleValue& outValue) {
const std::string trimmed = TrimAscii(text);
if (trimmed.empty()) {
return false;
}
outValue = UIStyleValue::Token(trimmed);
return true;
}
bool TryParsePropertyValue(
UIStylePropertyId propertyId,
const std::string& text,
UIStyleValue& outValue) {
const std::string trimmed = TrimAscii(text);
if (trimmed.empty()) {
return false;
}
const UIStyleValueType expectedType = GetExpectedValueType(propertyId);
switch (expectedType) {
case UIStyleValueType::Color: {
Color color = {};
if (TryParseColorValue(trimmed, color)) {
outValue = UIStyleValue(color);
return true;
}
return TryParseTokenReferenceValue(trimmed, outValue);
}
case UIStyleValueType::Float: {
float value = 0.0f;
if (TryParseFloat(trimmed, value)) {
outValue = UIStyleValue(value);
return true;
}
return TryParseTokenReferenceValue(trimmed, outValue);
}
case UIStyleValueType::Thickness: {
UIThickness thickness = {};
if (TryParseThicknessValue(trimmed, thickness)) {
outValue = UIStyleValue(thickness);
return true;
}
float uniform = 0.0f;
if (TryParseFloat(trimmed, uniform)) {
outValue = UIStyleValue(uniform);
return true;
}
return TryParseTokenReferenceValue(trimmed, outValue);
}
case UIStyleValueType::CornerRadius: {
UICornerRadius radius = {};
if (TryParseCornerRadiusValue(trimmed, radius)) {
outValue = UIStyleValue(radius);
return true;
}
float uniform = 0.0f;
if (TryParseFloat(trimmed, uniform)) {
outValue = UIStyleValue(uniform);
return true;
}
return TryParseTokenReferenceValue(trimmed, outValue);
}
default:
return false;
}
}
bool TryParseThemeTokenValue(const UIDocumentNode& tokenNode, UIStyleValue& outValue) {
const std::string tokenTag = ToLowerAscii(ToStdString(tokenNode.tagName));
const std::string valueText = GetAttribute(tokenNode, "value");
if (valueText.empty()) {
return false;
}
if (tokenTag == "color") {
Color color = {};
if (TryParseColorValue(valueText, color)) {
outValue = UIStyleValue(color);
return true;
}
return TryParseTokenReferenceValue(valueText, outValue);
}
if (tokenTag == "spacing" || tokenTag == "float" || tokenTag == "number") {
float value = 0.0f;
if (TryParseFloat(valueText, value)) {
outValue = UIStyleValue(value);
return true;
}
return TryParseTokenReferenceValue(valueText, outValue);
}
if (tokenTag == "radius") {
UICornerRadius radius = {};
if (TryParseCornerRadiusValue(valueText, radius)) {
outValue = UIStyleValue(radius);
return true;
}
float uniform = 0.0f;
if (TryParseFloat(valueText, uniform)) {
outValue = UIStyleValue(uniform);
return true;
}
return TryParseTokenReferenceValue(valueText, outValue);
}
if (tokenTag == "padding" || tokenTag == "thickness") {
UIThickness thickness = {};
if (TryParseThicknessValue(valueText, thickness)) {
outValue = UIStyleValue(thickness);
return true;
}
float uniform = 0.0f;
if (TryParseFloat(valueText, uniform)) {
outValue = UIStyleValue(uniform);
return true;
}
return TryParseTokenReferenceValue(valueText, outValue);
}
return false;
}
UIStyleSet& SelectStyleSetForWidget(
const UIDocumentNode& widgetNode,
UIStyleSheet& styleSheet) {
const std::string styleName = TrimAscii(GetAttribute(widgetNode, "style"));
if (!styleName.empty()) {
if (ToLowerAscii(styleName) == "default") {
return styleSheet.DefaultStyle();
}
return styleSheet.GetOrCreateNamedStyle(styleName);
}
const std::string typeName = TrimAscii(GetAttribute(widgetNode, "type"));
if (typeName.empty()) {
return styleSheet.DefaultStyle();
}
const std::string normalized = ToLowerAscii(typeName);
if (normalized == "*" || normalized == "default") {
return styleSheet.DefaultStyle();
}
return styleSheet.GetOrCreateTypeStyle(typeName);
}
bool ParseTokensNode(
const UIDocumentNode& tokensNode,
UIThemeDefinition& outDefinition,
std::string& outErrorMessage) {
for (const UIDocumentNode& tokenNode : tokensNode.children) {
const std::string name = TrimAscii(GetAttribute(tokenNode, "name"));
if (name.empty()) {
outErrorMessage = "Theme token is missing required 'name' attribute.";
return false;
}
UIStyleValue value = {};
if (!TryParseThemeTokenValue(tokenNode, value)) {
outErrorMessage = "Theme token '" + name + "' has an unsupported value.";
return false;
}
outDefinition.SetToken(name, value);
}
return true;
}
bool ParseWidgetsNode(
const UIDocumentNode& widgetsNode,
UIStyleSheet& outStyleSheet,
std::string& outErrorMessage) {
for (const UIDocumentNode& widgetNode : widgetsNode.children) {
if (widgetNode.tagName != "Widget") {
outErrorMessage = "Theme <Widgets> only supports <Widget> children.";
return false;
}
const std::string styleName = TrimAscii(GetAttribute(widgetNode, "style"));
const std::string typeName = TrimAscii(GetAttribute(widgetNode, "type"));
if (styleName.empty() && typeName.empty()) {
outErrorMessage = "Theme <Widget> must declare either 'type' or 'style'.";
return false;
}
UIStyleSet& styleSet = SelectStyleSetForWidget(widgetNode, outStyleSheet);
for (const UIDocumentNode& propertyNode : widgetNode.children) {
if (propertyNode.tagName != "Property") {
outErrorMessage = "Theme <Widget> only supports <Property> children.";
return false;
}
UIStylePropertyId propertyId = UIStylePropertyId::BackgroundColor;
const std::string propertyName = GetAttribute(propertyNode, "name");
if (!TryMapPropertyName(propertyName, propertyId)) {
outErrorMessage = "Theme property '" + propertyName + "' is unsupported.";
return false;
}
UIStyleValue value = {};
if (!TryParsePropertyValue(propertyId, GetAttribute(propertyNode, "value"), value)) {
outErrorMessage = "Theme property '" + propertyName + "' has an unsupported value.";
return false;
}
styleSet.SetProperty(propertyId, value);
}
}
return true;
}
} // namespace
UIDocumentStyleCompileResult CompileDocumentStyle(const UIDocumentModel& themeDocument) {
UIDocumentStyleCompileResult result = {};
result.succeeded = TryCompileDocumentStyle(
themeDocument,
result.theme,
result.styleSheet,
&result.errorMessage);
return result;
}
bool TryCompileDocumentStyle(
const UIDocumentModel& themeDocument,
UITheme& outTheme,
UIStyleSheet& outStyleSheet,
std::string* outErrorMessage) {
outTheme = {};
outStyleSheet = {};
if (outErrorMessage != nullptr) {
outErrorMessage->clear();
}
if (!themeDocument.valid || themeDocument.rootNode.tagName.Empty()) {
if (outErrorMessage != nullptr) {
*outErrorMessage = "Theme document is invalid.";
}
return false;
}
if (themeDocument.rootNode.tagName != "Theme") {
if (outErrorMessage != nullptr) {
*outErrorMessage = "Theme document root tag must be <Theme>.";
}
return false;
}
UIThemeDefinition definition = {};
definition.name = GetAttribute(themeDocument.rootNode, "name");
std::string errorMessage = {};
for (const UIDocumentNode& childNode : themeDocument.rootNode.children) {
if (childNode.tagName == "Tokens") {
if (!ParseTokensNode(childNode, definition, errorMessage)) {
if (outErrorMessage != nullptr) {
*outErrorMessage = errorMessage;
}
return false;
}
continue;
}
if (childNode.tagName == "Widgets") {
if (!ParseWidgetsNode(childNode, outStyleSheet, errorMessage)) {
if (outErrorMessage != nullptr) {
*outErrorMessage = errorMessage;
}
return false;
}
}
}
outTheme = BuildTheme(definition);
return true;
}
UIStyleSet BuildInlineStyle(const UIDocumentNode& node) {
UIStyleSet localStyle = {};
for (const UIDocumentAttribute& attribute : node.attributes) {
UIStylePropertyId propertyId = UIStylePropertyId::BackgroundColor;
if (!TryMapPropertyName(ToStdString(attribute.name), propertyId)) {
continue;
}
UIStyleValue value = {};
if (!TryParsePropertyValue(propertyId, ToStdString(attribute.value), value)) {
continue;
}
localStyle.SetProperty(propertyId, value);
}
return localStyle;
}
} // namespace Style
} // namespace UI
} // namespace XCEngine

View File

@@ -6,6 +6,36 @@ namespace Style {
namespace { namespace {
bool TryCoerceResolvedValue(
const UIStyleValue& assignedValue,
UIStyleValueType expectedType,
UIStyleValue& outValue) {
if (!assignedValue.IsSet()) {
return false;
}
if (expectedType == UIStyleValueType::None || assignedValue.GetType() == expectedType) {
outValue = assignedValue;
return true;
}
if (expectedType == UIStyleValueType::Thickness) {
if (const float* uniform = assignedValue.TryGetFloat()) {
outValue = UIStyleValue(UIThickness::Uniform(*uniform));
return true;
}
}
if (expectedType == UIStyleValueType::CornerRadius) {
if (const float* uniform = assignedValue.TryGetFloat()) {
outValue = UIStyleValue(UICornerRadius::Uniform(*uniform));
return true;
}
}
return false;
}
bool TryResolveAssignedValue( bool TryResolveAssignedValue(
const UIStyleValue& assignedValue, const UIStyleValue& assignedValue,
UIStyleValueType expectedType, UIStyleValueType expectedType,
@@ -20,21 +50,15 @@ bool TryResolveAssignedValue(
return false; return false;
} }
const UITokenResolveResult tokenResult = theme->ResolveToken(tokenReference->name, expectedType); const UITokenResolveResult tokenResult = theme->ResolveToken(tokenReference->name, UIStyleValueType::None);
if (tokenResult.status != UITokenResolveStatus::Resolved) { if (tokenResult.status != UITokenResolveStatus::Resolved) {
return false; return false;
} }
outValue = tokenResult.value; return TryCoerceResolvedValue(tokenResult.value, expectedType, outValue);
return true;
} }
if (expectedType != UIStyleValueType::None && assignedValue.GetType() != expectedType) { return TryCoerceResolvedValue(assignedValue, expectedType, outValue);
return false;
}
outValue = assignedValue;
return true;
} }
const UIStyleSet* GetStyleSetForLayer(UIStyleLayer layer, const UIStyleResolveContext& context) { const UIStyleSet* GetStyleSetForLayer(UIStyleLayer layer, const UIStyleResolveContext& context) {

View File

@@ -13,7 +13,10 @@ set(NEW_EDITOR_RESOURCE_FILES
add_library(XCNewEditorLib STATIC add_library(XCNewEditorLib STATIC
src/editor/EditorShellAsset.cpp src/editor/EditorShellAsset.cpp
src/editor/UIEditorPanelRegistry.cpp
src/editor/UIEditorWorkspaceController.cpp
src/editor/UIEditorWorkspaceModel.cpp src/editor/UIEditorWorkspaceModel.cpp
src/editor/UIEditorWorkspaceSession.cpp
src/Widgets/UIEditorCollectionPrimitives.cpp src/Widgets/UIEditorCollectionPrimitives.cpp
) )

View File

@@ -0,0 +1,52 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine::NewEditor {
enum class UIEditorPanelPresentationKind : std::uint8_t {
Placeholder = 0
};
struct UIEditorPanelDescriptor {
std::string panelId = {};
std::string defaultTitle = {};
UIEditorPanelPresentationKind presentationKind = UIEditorPanelPresentationKind::Placeholder;
bool placeholder = true;
bool canHide = true;
bool canClose = true;
};
struct UIEditorPanelRegistry {
std::vector<UIEditorPanelDescriptor> panels = {};
};
enum class UIEditorPanelRegistryValidationCode : std::uint8_t {
None = 0,
EmptyPanelId,
EmptyDefaultTitle,
DuplicatePanelId
};
struct UIEditorPanelRegistryValidationResult {
UIEditorPanelRegistryValidationCode code = UIEditorPanelRegistryValidationCode::None;
std::string message = {};
[[nodiscard]] bool IsValid() const {
return code == UIEditorPanelRegistryValidationCode::None;
}
};
UIEditorPanelRegistry BuildDefaultEditorShellPanelRegistry();
const UIEditorPanelDescriptor* FindUIEditorPanelDescriptor(
const UIEditorPanelRegistry& registry,
std::string_view panelId);
UIEditorPanelRegistryValidationResult ValidateUIEditorPanelRegistry(
const UIEditorPanelRegistry& registry);
} // namespace XCEngine::NewEditor

View File

@@ -0,0 +1,111 @@
#pragma once
#include <XCNewEditor/Editor/UIEditorWorkspaceSession.h>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine::NewEditor {
enum class UIEditorWorkspaceCommandKind : std::uint8_t {
OpenPanel = 0,
ClosePanel,
ShowPanel,
HidePanel,
ActivatePanel,
ResetWorkspace
};
enum class UIEditorWorkspaceCommandStatus : std::uint8_t {
Changed = 0,
NoOp,
Rejected
};
struct UIEditorWorkspaceCommand {
UIEditorWorkspaceCommandKind kind = UIEditorWorkspaceCommandKind::ActivatePanel;
std::string panelId = {};
};
struct UIEditorWorkspaceCommandResult {
UIEditorWorkspaceCommandKind kind = UIEditorWorkspaceCommandKind::ActivatePanel;
UIEditorWorkspaceCommandStatus status = UIEditorWorkspaceCommandStatus::Rejected;
std::string panelId = {};
std::string message = {};
std::string activePanelId = {};
std::vector<std::string> visiblePanelIds = {};
};
enum class UIEditorWorkspaceControllerValidationCode : std::uint8_t {
None = 0,
InvalidPanelRegistry,
InvalidWorkspace,
InvalidWorkspaceSession
};
struct UIEditorWorkspaceControllerValidationResult {
UIEditorWorkspaceControllerValidationCode code =
UIEditorWorkspaceControllerValidationCode::None;
std::string message = {};
[[nodiscard]] bool IsValid() const {
return code == UIEditorWorkspaceControllerValidationCode::None;
}
};
std::string_view GetUIEditorWorkspaceCommandKindName(UIEditorWorkspaceCommandKind kind);
std::string_view GetUIEditorWorkspaceCommandStatusName(UIEditorWorkspaceCommandStatus status);
class UIEditorWorkspaceController {
public:
UIEditorWorkspaceController() = default;
UIEditorWorkspaceController(
UIEditorPanelRegistry panelRegistry,
UIEditorWorkspaceModel workspace,
UIEditorWorkspaceSession session);
const UIEditorPanelRegistry& GetPanelRegistry() const {
return m_panelRegistry;
}
const UIEditorWorkspaceModel& GetWorkspace() const {
return m_workspace;
}
const UIEditorWorkspaceSession& GetSession() const {
return m_session;
}
UIEditorWorkspaceControllerValidationResult ValidateState() const;
UIEditorWorkspaceCommandResult Dispatch(const UIEditorWorkspaceCommand& command);
private:
UIEditorWorkspaceCommandResult BuildResult(
const UIEditorWorkspaceCommand& command,
UIEditorWorkspaceCommandStatus status,
std::string message) const;
UIEditorWorkspaceCommandResult FinalizeMutation(
const UIEditorWorkspaceCommand& command,
bool changed,
std::string changedMessage,
std::string unexpectedFailureMessage,
const UIEditorWorkspaceModel& previousWorkspace,
const UIEditorWorkspaceSession& previousSession);
const UIEditorPanelDescriptor* FindPanelDescriptor(std::string_view panelId) const;
UIEditorPanelRegistry m_panelRegistry = {};
UIEditorWorkspaceModel m_baselineWorkspace = {};
UIEditorWorkspaceSession m_baselineSession = {};
UIEditorWorkspaceModel m_workspace = {};
UIEditorWorkspaceSession m_session = {};
};
UIEditorWorkspaceController BuildDefaultUIEditorWorkspaceController(
const UIEditorPanelRegistry& panelRegistry,
const UIEditorWorkspaceModel& workspace);
} // namespace XCEngine::NewEditor

View File

@@ -70,6 +70,8 @@ struct UIEditorWorkspaceVisiblePanel {
bool placeholder = false; bool placeholder = false;
}; };
UIEditorWorkspaceModel BuildDefaultEditorShellWorkspaceModel();
UIEditorWorkspaceNode BuildUIEditorWorkspacePanel( UIEditorWorkspaceNode BuildUIEditorWorkspacePanel(
std::string nodeId, std::string nodeId,
std::string panelId, std::string panelId,

View File

@@ -0,0 +1,94 @@
#pragma once
#include <XCNewEditor/Editor/UIEditorPanelRegistry.h>
#include <XCNewEditor/Editor/UIEditorWorkspaceModel.h>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine::NewEditor {
struct UIEditorPanelSessionState {
std::string panelId = {};
bool open = true;
bool visible = true;
};
struct UIEditorWorkspaceSession {
std::vector<UIEditorPanelSessionState> panelStates = {};
};
enum class UIEditorWorkspaceSessionValidationCode : std::uint8_t {
None = 0,
MissingPanelState,
UnknownPanelId,
DuplicatePanelId,
ClosedPanelVisible,
NonHideablePanelHidden,
NonCloseablePanelClosed,
InvalidActivePanelId
};
struct UIEditorWorkspaceSessionValidationResult {
UIEditorWorkspaceSessionValidationCode code = UIEditorWorkspaceSessionValidationCode::None;
std::string message = {};
[[nodiscard]] bool IsValid() const {
return code == UIEditorWorkspaceSessionValidationCode::None;
}
};
UIEditorWorkspaceSession BuildDefaultUIEditorWorkspaceSession(
const UIEditorPanelRegistry& panelRegistry,
const UIEditorWorkspaceModel& workspace);
const UIEditorPanelSessionState* FindUIEditorPanelSessionState(
const UIEditorWorkspaceSession& session,
std::string_view panelId);
UIEditorWorkspaceSessionValidationResult ValidateUIEditorWorkspaceSession(
const UIEditorPanelRegistry& panelRegistry,
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session);
std::vector<UIEditorWorkspaceVisiblePanel> CollectUIEditorWorkspaceVisiblePanels(
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session);
const UIEditorWorkspacePanelState* FindUIEditorWorkspaceActivePanel(
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session);
bool TryOpenUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId);
bool TryCloseUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId);
bool TryShowUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId);
bool TryHideUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId);
bool TryActivateUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId);
} // namespace XCEngine::NewEditor

View File

@@ -82,6 +82,17 @@ std::string FormatPoint(const UIPoint& point) {
return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")"; return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")";
} }
void AppendErrorMessage(std::string& target, const std::string& message) {
if (message.empty()) {
return;
}
if (!target.empty()) {
target += " | ";
}
target += message;
}
std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) { std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) {
switch (wParam) { switch (wParam) {
case 'A': return static_cast<std::int32_t>(KeyCode::A); case 'A': return static_cast<std::int32_t>(KeyCode::A);
@@ -392,9 +403,19 @@ bool Application::LoadStructuredScreen(const char* triggerReason) {
m_screenAsset.themePath = m_shellAssetDefinition.themePath.string(); m_screenAsset.themePath = m_shellAssetDefinition.themePath.string();
const bool loaded = m_screenPlayer.Load(m_screenAsset); const bool loaded = m_screenPlayer.Load(m_screenAsset);
const EditorShellAssetValidationResult shellAssetValidation =
ValidateEditorShellAsset(m_shellAssetDefinition);
m_useStructuredScreen = loaded; m_useStructuredScreen = loaded;
m_runtimeStatus = loaded ? "XCUI Editor Shell" : "Editor Shell | Load Error"; m_runtimeStatus = loaded ? "XCUI Editor Shell" : "Editor Shell | Load Error";
m_runtimeError = loaded ? std::string() : m_screenPlayer.GetLastError(); m_runtimeError.clear();
if (!loaded) {
AppendErrorMessage(m_runtimeError, m_screenPlayer.GetLastError());
}
if (!shellAssetValidation.IsValid()) {
AppendErrorMessage(
m_runtimeError,
"Editor shell asset invalid: " + shellAssetValidation.message);
}
RebuildTrackedFileStates(); RebuildTrackedFileStates();
return loaded; return loaded;
} }

View File

@@ -1,13 +1,106 @@
#include "EditorShellAsset.h" #include "EditorShellAsset.h"
#include <utility>
namespace XCEngine::NewEditor { namespace XCEngine::NewEditor {
namespace {
EditorShellAssetValidationResult MakeValidationError(
EditorShellAssetValidationCode code,
std::string message) {
EditorShellAssetValidationResult result = {};
result.code = code;
result.message = std::move(message);
return result;
}
EditorShellAssetValidationResult ValidateWorkspacePanelsAgainstRegistry(
const UIEditorWorkspaceNode& node,
const UIEditorPanelRegistry& panelRegistry) {
if (node.kind == UIEditorWorkspaceNodeKind::Panel) {
const UIEditorPanelDescriptor* descriptor =
FindUIEditorPanelDescriptor(panelRegistry, node.panel.panelId);
if (descriptor == nullptr) {
return MakeValidationError(
EditorShellAssetValidationCode::MissingPanelDescriptor,
"Workspace panel '" + node.panel.panelId + "' is missing from the panel registry.");
}
if (node.panel.title != descriptor->defaultTitle) {
return MakeValidationError(
EditorShellAssetValidationCode::PanelTitleMismatch,
"Workspace panel '" + node.panel.panelId + "' title does not match the registry default title.");
}
if (node.panel.placeholder != descriptor->placeholder) {
return MakeValidationError(
EditorShellAssetValidationCode::PanelPlaceholderMismatch,
"Workspace panel '" + node.panel.panelId + "' placeholder flag does not match the registry descriptor.");
}
return {};
}
for (const UIEditorWorkspaceNode& child : node.children) {
EditorShellAssetValidationResult result =
ValidateWorkspacePanelsAgainstRegistry(child, panelRegistry);
if (!result.IsValid()) {
return result;
}
}
return {};
}
} // namespace
EditorShellAsset BuildDefaultEditorShellAsset(const std::filesystem::path& repoRoot) { EditorShellAsset BuildDefaultEditorShellAsset(const std::filesystem::path& repoRoot) {
EditorShellAsset asset = {}; EditorShellAsset asset = {};
asset.documentPath = (repoRoot / "new_editor/ui/views/editor_shell.xcui").lexically_normal(); 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.themePath = (repoRoot / "new_editor/ui/themes/editor_shell.xctheme").lexically_normal();
asset.captureRootPath = (repoRoot / "new_editor/captures").lexically_normal(); asset.captureRootPath = (repoRoot / "new_editor/captures").lexically_normal();
asset.panelRegistry = BuildDefaultEditorShellPanelRegistry();
asset.workspace = BuildDefaultEditorShellWorkspaceModel();
asset.workspaceSession = BuildDefaultUIEditorWorkspaceSession(asset.panelRegistry, asset.workspace);
return asset; return asset;
} }
EditorShellAssetValidationResult ValidateEditorShellAsset(const EditorShellAsset& asset) {
const UIEditorPanelRegistryValidationResult registryValidation =
ValidateUIEditorPanelRegistry(asset.panelRegistry);
if (!registryValidation.IsValid()) {
return MakeValidationError(
EditorShellAssetValidationCode::InvalidPanelRegistry,
registryValidation.message);
}
const UIEditorWorkspaceValidationResult workspaceValidation =
ValidateUIEditorWorkspace(asset.workspace);
if (!workspaceValidation.IsValid()) {
return MakeValidationError(
EditorShellAssetValidationCode::InvalidWorkspace,
workspaceValidation.message);
}
const EditorShellAssetValidationResult panelRegistryConsistency =
ValidateWorkspacePanelsAgainstRegistry(asset.workspace.root, asset.panelRegistry);
if (!panelRegistryConsistency.IsValid()) {
return panelRegistryConsistency;
}
const UIEditorWorkspaceSessionValidationResult workspaceSessionValidation =
ValidateUIEditorWorkspaceSession(
asset.panelRegistry,
asset.workspace,
asset.workspaceSession);
if (!workspaceSessionValidation.IsValid()) {
return MakeValidationError(
EditorShellAssetValidationCode::InvalidWorkspaceSession,
workspaceSessionValidation.message);
}
return {};
}
} // namespace XCEngine::NewEditor } // namespace XCEngine::NewEditor

View File

@@ -1,5 +1,10 @@
#pragma once #pragma once
#include <XCNewEditor/Editor/UIEditorPanelRegistry.h>
#include <XCNewEditor/Editor/UIEditorWorkspaceModel.h>
#include <XCNewEditor/Editor/UIEditorWorkspaceSession.h>
#include <cstdint>
#include <filesystem> #include <filesystem>
#include <string> #include <string>
@@ -10,8 +15,31 @@ struct EditorShellAsset {
std::filesystem::path documentPath = {}; std::filesystem::path documentPath = {};
std::filesystem::path themePath = {}; std::filesystem::path themePath = {};
std::filesystem::path captureRootPath = {}; std::filesystem::path captureRootPath = {};
UIEditorPanelRegistry panelRegistry = {};
UIEditorWorkspaceModel workspace = {};
UIEditorWorkspaceSession workspaceSession = {};
};
enum class EditorShellAssetValidationCode : std::uint8_t {
None = 0,
InvalidPanelRegistry,
InvalidWorkspace,
InvalidWorkspaceSession,
MissingPanelDescriptor,
PanelTitleMismatch,
PanelPlaceholderMismatch
};
struct EditorShellAssetValidationResult {
EditorShellAssetValidationCode code = EditorShellAssetValidationCode::None;
std::string message = {};
[[nodiscard]] bool IsValid() const {
return code == EditorShellAssetValidationCode::None;
}
}; };
EditorShellAsset BuildDefaultEditorShellAsset(const std::filesystem::path& repoRoot); EditorShellAsset BuildDefaultEditorShellAsset(const std::filesystem::path& repoRoot);
EditorShellAssetValidationResult ValidateEditorShellAsset(const EditorShellAsset& asset);
} // namespace XCEngine::NewEditor } // namespace XCEngine::NewEditor

View File

@@ -0,0 +1,74 @@
#include <XCNewEditor/Editor/UIEditorPanelRegistry.h>
#include <unordered_set>
#include <utility>
namespace XCEngine::NewEditor {
namespace {
UIEditorPanelRegistryValidationResult MakeValidationError(
UIEditorPanelRegistryValidationCode code,
std::string message) {
UIEditorPanelRegistryValidationResult result = {};
result.code = code;
result.message = std::move(message);
return result;
}
} // namespace
UIEditorPanelRegistry BuildDefaultEditorShellPanelRegistry() {
UIEditorPanelRegistry registry = {};
registry.panels = {
{
"editor-foundation-root",
"Root Surface",
UIEditorPanelPresentationKind::Placeholder,
true,
false,
false
}
};
return registry;
}
const UIEditorPanelDescriptor* FindUIEditorPanelDescriptor(
const UIEditorPanelRegistry& registry,
std::string_view panelId) {
for (const UIEditorPanelDescriptor& descriptor : registry.panels) {
if (descriptor.panelId == panelId) {
return &descriptor;
}
}
return nullptr;
}
UIEditorPanelRegistryValidationResult ValidateUIEditorPanelRegistry(
const UIEditorPanelRegistry& registry) {
std::unordered_set<std::string> panelIds = {};
for (const UIEditorPanelDescriptor& descriptor : registry.panels) {
if (descriptor.panelId.empty()) {
return MakeValidationError(
UIEditorPanelRegistryValidationCode::EmptyPanelId,
"Panel registry entry must define a panelId.");
}
if (descriptor.defaultTitle.empty()) {
return MakeValidationError(
UIEditorPanelRegistryValidationCode::EmptyDefaultTitle,
"Panel descriptor '" + descriptor.panelId + "' must define a defaultTitle.");
}
if (!panelIds.insert(descriptor.panelId).second) {
return MakeValidationError(
UIEditorPanelRegistryValidationCode::DuplicatePanelId,
"Panel descriptor '" + descriptor.panelId + "' is duplicated in the registry.");
}
}
return {};
}
} // namespace XCEngine::NewEditor

View File

@@ -0,0 +1,357 @@
#include <XCNewEditor/Editor/UIEditorWorkspaceController.h>
#include <utility>
namespace XCEngine::NewEditor {
namespace {
bool AreWorkspaceNodesEquivalent(
const UIEditorWorkspaceNode& lhs,
const UIEditorWorkspaceNode& rhs) {
if (lhs.kind != rhs.kind ||
lhs.nodeId != rhs.nodeId ||
lhs.splitAxis != rhs.splitAxis ||
lhs.splitRatio != rhs.splitRatio ||
lhs.selectedTabIndex != rhs.selectedTabIndex ||
lhs.panel.panelId != rhs.panel.panelId ||
lhs.panel.title != rhs.panel.title ||
lhs.panel.placeholder != rhs.panel.placeholder ||
lhs.children.size() != rhs.children.size()) {
return false;
}
for (std::size_t index = 0; index < lhs.children.size(); ++index) {
if (!AreWorkspaceNodesEquivalent(lhs.children[index], rhs.children[index])) {
return false;
}
}
return true;
}
bool AreWorkspaceModelsEquivalent(
const UIEditorWorkspaceModel& lhs,
const UIEditorWorkspaceModel& rhs) {
return lhs.activePanelId == rhs.activePanelId &&
AreWorkspaceNodesEquivalent(lhs.root, rhs.root);
}
bool AreWorkspaceSessionsEquivalent(
const UIEditorWorkspaceSession& lhs,
const UIEditorWorkspaceSession& rhs) {
if (lhs.panelStates.size() != rhs.panelStates.size()) {
return false;
}
for (std::size_t index = 0; index < lhs.panelStates.size(); ++index) {
const UIEditorPanelSessionState& lhsState = lhs.panelStates[index];
const UIEditorPanelSessionState& rhsState = rhs.panelStates[index];
if (lhsState.panelId != rhsState.panelId ||
lhsState.open != rhsState.open ||
lhsState.visible != rhsState.visible) {
return false;
}
}
return true;
}
std::vector<std::string> CollectVisiblePanelIds(
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session) {
const std::vector<UIEditorWorkspaceVisiblePanel> panels =
CollectUIEditorWorkspaceVisiblePanels(workspace, session);
std::vector<std::string> ids = {};
ids.reserve(panels.size());
for (const UIEditorWorkspaceVisiblePanel& panel : panels) {
ids.push_back(panel.panelId);
}
return ids;
}
} // namespace
std::string_view GetUIEditorWorkspaceCommandKindName(UIEditorWorkspaceCommandKind kind) {
switch (kind) {
case UIEditorWorkspaceCommandKind::OpenPanel:
return "OpenPanel";
case UIEditorWorkspaceCommandKind::ClosePanel:
return "ClosePanel";
case UIEditorWorkspaceCommandKind::ShowPanel:
return "ShowPanel";
case UIEditorWorkspaceCommandKind::HidePanel:
return "HidePanel";
case UIEditorWorkspaceCommandKind::ActivatePanel:
return "ActivatePanel";
case UIEditorWorkspaceCommandKind::ResetWorkspace:
return "ResetWorkspace";
}
return "Unknown";
}
std::string_view GetUIEditorWorkspaceCommandStatusName(UIEditorWorkspaceCommandStatus status) {
switch (status) {
case UIEditorWorkspaceCommandStatus::Changed:
return "Changed";
case UIEditorWorkspaceCommandStatus::NoOp:
return "NoOp";
case UIEditorWorkspaceCommandStatus::Rejected:
return "Rejected";
}
return "Unknown";
}
UIEditorWorkspaceController::UIEditorWorkspaceController(
UIEditorPanelRegistry panelRegistry,
UIEditorWorkspaceModel workspace,
UIEditorWorkspaceSession session)
: m_panelRegistry(std::move(panelRegistry))
, m_baselineWorkspace(workspace)
, m_baselineSession(session)
, m_workspace(std::move(workspace))
, m_session(std::move(session)) {
}
UIEditorWorkspaceControllerValidationResult UIEditorWorkspaceController::ValidateState() const {
const UIEditorPanelRegistryValidationResult registryValidation =
ValidateUIEditorPanelRegistry(m_panelRegistry);
if (!registryValidation.IsValid()) {
UIEditorWorkspaceControllerValidationResult result = {};
result.code = UIEditorWorkspaceControllerValidationCode::InvalidPanelRegistry;
result.message = registryValidation.message;
return result;
}
const UIEditorWorkspaceValidationResult workspaceValidation =
ValidateUIEditorWorkspace(m_workspace);
if (!workspaceValidation.IsValid()) {
UIEditorWorkspaceControllerValidationResult result = {};
result.code = UIEditorWorkspaceControllerValidationCode::InvalidWorkspace;
result.message = workspaceValidation.message;
return result;
}
const UIEditorWorkspaceSessionValidationResult sessionValidation =
ValidateUIEditorWorkspaceSession(m_panelRegistry, m_workspace, m_session);
if (!sessionValidation.IsValid()) {
UIEditorWorkspaceControllerValidationResult result = {};
result.code = UIEditorWorkspaceControllerValidationCode::InvalidWorkspaceSession;
result.message = sessionValidation.message;
return result;
}
return {};
}
UIEditorWorkspaceCommandResult UIEditorWorkspaceController::BuildResult(
const UIEditorWorkspaceCommand& command,
UIEditorWorkspaceCommandStatus status,
std::string message) const {
UIEditorWorkspaceCommandResult result = {};
result.kind = command.kind;
result.status = status;
result.panelId = command.panelId;
result.message = std::move(message);
result.activePanelId = m_workspace.activePanelId;
result.visiblePanelIds = CollectVisiblePanelIds(m_workspace, m_session);
return result;
}
UIEditorWorkspaceCommandResult UIEditorWorkspaceController::FinalizeMutation(
const UIEditorWorkspaceCommand& command,
bool changed,
std::string changedMessage,
std::string unexpectedFailureMessage,
const UIEditorWorkspaceModel& previousWorkspace,
const UIEditorWorkspaceSession& previousSession) {
if (!changed) {
return BuildResult(
command,
UIEditorWorkspaceCommandStatus::Rejected,
std::move(unexpectedFailureMessage));
}
const UIEditorWorkspaceControllerValidationResult validation = ValidateState();
if (!validation.IsValid()) {
m_workspace = previousWorkspace;
m_session = previousSession;
return BuildResult(
command,
UIEditorWorkspaceCommandStatus::Rejected,
"Command produced invalid workspace state: " + validation.message);
}
return BuildResult(
command,
UIEditorWorkspaceCommandStatus::Changed,
std::move(changedMessage));
}
const UIEditorPanelDescriptor* UIEditorWorkspaceController::FindPanelDescriptor(
std::string_view panelId) const {
return FindUIEditorPanelDescriptor(m_panelRegistry, panelId);
}
UIEditorWorkspaceCommandResult UIEditorWorkspaceController::Dispatch(
const UIEditorWorkspaceCommand& command) {
const UIEditorWorkspaceControllerValidationResult validation = ValidateState();
if (command.kind != UIEditorWorkspaceCommandKind::ResetWorkspace &&
!validation.IsValid()) {
return BuildResult(
command,
UIEditorWorkspaceCommandStatus::Rejected,
"Controller state invalid: " + validation.message);
}
const UIEditorWorkspaceModel previousWorkspace = m_workspace;
const UIEditorWorkspaceSession previousSession = m_session;
const UIEditorPanelSessionState* panelState =
command.kind == UIEditorWorkspaceCommandKind::ResetWorkspace
? nullptr
: FindUIEditorPanelSessionState(m_session, command.panelId);
const UIEditorPanelDescriptor* panelDescriptor =
command.kind == UIEditorWorkspaceCommandKind::ResetWorkspace
? nullptr
: FindPanelDescriptor(command.panelId);
switch (command.kind) {
case UIEditorWorkspaceCommandKind::OpenPanel:
if (command.panelId.empty()) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "OpenPanel requires a panelId.");
}
if (panelDescriptor == nullptr || panelState == nullptr) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "OpenPanel target panel is missing.");
}
if (panelState->open && panelState->visible) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already open and visible.");
}
return FinalizeMutation(
command,
TryOpenUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
"Panel opened and activated.",
"OpenPanel failed unexpectedly.",
previousWorkspace,
previousSession);
case UIEditorWorkspaceCommandKind::ClosePanel:
if (command.panelId.empty()) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ClosePanel requires a panelId.");
}
if (panelDescriptor == nullptr || panelState == nullptr) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ClosePanel target panel is missing.");
}
if (!panelDescriptor->canClose) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Panel cannot be closed.");
}
if (!panelState->open) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already closed.");
}
return FinalizeMutation(
command,
TryCloseUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
"Panel closed.",
"ClosePanel failed unexpectedly.",
previousWorkspace,
previousSession);
case UIEditorWorkspaceCommandKind::ShowPanel:
if (command.panelId.empty()) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ShowPanel requires a panelId.");
}
if (panelDescriptor == nullptr || panelState == nullptr) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ShowPanel target panel is missing.");
}
if (!panelState->open) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Closed panel must be opened before it can be shown.");
}
if (panelState->visible) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already visible.");
}
return FinalizeMutation(
command,
TryShowUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
"Panel shown and activated.",
"ShowPanel failed unexpectedly.",
previousWorkspace,
previousSession);
case UIEditorWorkspaceCommandKind::HidePanel:
if (command.panelId.empty()) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "HidePanel requires a panelId.");
}
if (panelDescriptor == nullptr || panelState == nullptr) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "HidePanel target panel is missing.");
}
if (!panelDescriptor->canHide) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Panel cannot be hidden.");
}
if (!panelState->open) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Closed panel cannot be hidden.");
}
if (!panelState->visible) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already hidden.");
}
return FinalizeMutation(
command,
TryHideUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
"Panel hidden and active panel re-resolved.",
"HidePanel failed unexpectedly.",
previousWorkspace,
previousSession);
case UIEditorWorkspaceCommandKind::ActivatePanel:
if (command.panelId.empty()) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ActivatePanel requires a panelId.");
}
if (panelDescriptor == nullptr || panelState == nullptr) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ActivatePanel target panel is missing.");
}
if (!panelState->open || !panelState->visible) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Only open and visible panels can be activated.");
}
if (m_workspace.activePanelId == command.panelId) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already active.");
}
return FinalizeMutation(
command,
TryActivateUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
"Panel activated.",
"ActivatePanel failed unexpectedly.",
previousWorkspace,
previousSession);
case UIEditorWorkspaceCommandKind::ResetWorkspace:
if (AreWorkspaceModelsEquivalent(m_workspace, m_baselineWorkspace) &&
AreWorkspaceSessionsEquivalent(m_session, m_baselineSession)) {
return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Workspace already matches the baseline state.");
}
m_workspace = m_baselineWorkspace;
m_session = m_baselineSession;
return FinalizeMutation(
command,
true,
"Workspace reset to baseline.",
"ResetWorkspace failed unexpectedly.",
previousWorkspace,
previousSession);
}
return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Unknown command kind.");
}
UIEditorWorkspaceController BuildDefaultUIEditorWorkspaceController(
const UIEditorPanelRegistry& panelRegistry,
const UIEditorWorkspaceModel& workspace) {
return UIEditorWorkspaceController(
panelRegistry,
workspace,
BuildDefaultUIEditorWorkspaceSession(panelRegistry, workspace));
}
} // namespace XCEngine::NewEditor

View File

@@ -1,3 +1,4 @@
#include <XCNewEditor/Editor/UIEditorPanelRegistry.h>
#include <XCNewEditor/Editor/UIEditorWorkspaceModel.h> #include <XCNewEditor/Editor/UIEditorWorkspaceModel.h>
#include <cmath> #include <cmath>
@@ -21,6 +22,18 @@ bool IsValidSplitRatio(float value) {
return std::isfinite(value) && value > 0.0f && value < 1.0f; return std::isfinite(value) && value > 0.0f && value < 1.0f;
} }
const UIEditorPanelDescriptor& RequirePanelDescriptor(
const UIEditorPanelRegistry& registry,
std::string_view panelId) {
if (const UIEditorPanelDescriptor* descriptor = FindUIEditorPanelDescriptor(registry, panelId);
descriptor != nullptr) {
return *descriptor;
}
static const UIEditorPanelDescriptor fallbackDescriptor = {};
return fallbackDescriptor;
}
const UIEditorWorkspacePanelState* FindPanelRecursive( const UIEditorWorkspacePanelState* FindPanelRecursive(
const UIEditorWorkspaceNode& node, const UIEditorWorkspaceNode& node,
std::string_view panelId) { std::string_view panelId) {
@@ -192,6 +205,21 @@ UIEditorWorkspaceValidationResult ValidateNodeRecursive(
} // namespace } // namespace
UIEditorWorkspaceModel BuildDefaultEditorShellWorkspaceModel() {
const UIEditorPanelRegistry registry = BuildDefaultEditorShellPanelRegistry();
const UIEditorPanelDescriptor& rootPanel =
RequirePanelDescriptor(registry, "editor-foundation-root");
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspacePanel(
"editor-foundation-root-node",
rootPanel.panelId,
rootPanel.defaultTitle,
rootPanel.placeholder);
workspace.activePanelId = rootPanel.panelId;
return workspace;
}
UIEditorWorkspaceNode BuildUIEditorWorkspacePanel( UIEditorWorkspaceNode BuildUIEditorWorkspacePanel(
std::string nodeId, std::string nodeId,
std::string panelId, std::string panelId,

View File

@@ -0,0 +1,476 @@
#include <XCNewEditor/Editor/UIEditorWorkspaceSession.h>
#include <unordered_set>
#include <utility>
namespace XCEngine::NewEditor {
namespace {
UIEditorWorkspaceSessionValidationResult MakeValidationError(
UIEditorWorkspaceSessionValidationCode code,
std::string message) {
UIEditorWorkspaceSessionValidationResult result = {};
result.code = code;
result.message = std::move(message);
return result;
}
UIEditorPanelSessionState* FindMutablePanelSessionState(
UIEditorWorkspaceSession& session,
std::string_view panelId) {
for (UIEditorPanelSessionState& state : session.panelStates) {
if (state.panelId == panelId) {
return &state;
}
}
return nullptr;
}
const UIEditorPanelDescriptor* FindPanelDescriptor(
const UIEditorPanelRegistry& panelRegistry,
std::string_view panelId) {
return FindUIEditorPanelDescriptor(panelRegistry, panelId);
}
const UIEditorWorkspacePanelState* FindPanelRecursive(
const UIEditorWorkspaceNode& node,
std::string_view panelId) {
if (node.kind == UIEditorWorkspaceNodeKind::Panel) {
return node.panel.panelId == panelId ? &node.panel : nullptr;
}
for (const UIEditorWorkspaceNode& child : node.children) {
if (const UIEditorWorkspacePanelState* found = FindPanelRecursive(child, panelId)) {
return found;
}
}
return nullptr;
}
void CollectWorkspacePanelIdsRecursive(
const UIEditorWorkspaceNode& node,
std::vector<std::string>& outPanelIds) {
if (node.kind == UIEditorWorkspaceNodeKind::Panel) {
outPanelIds.push_back(node.panel.panelId);
return;
}
for (const UIEditorWorkspaceNode& child : node.children) {
CollectWorkspacePanelIdsRecursive(child, outPanelIds);
}
}
bool IsPanelOpenAndVisible(
const UIEditorWorkspaceSession& session,
std::string_view panelId) {
const UIEditorPanelSessionState* state = FindUIEditorPanelSessionState(session, panelId);
return state != nullptr && state->open && state->visible;
}
bool IsPanelSelectable(
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session,
std::string_view panelId) {
return !panelId.empty() &&
IsPanelOpenAndVisible(session, panelId) &&
ContainsUIEditorWorkspacePanel(workspace, panelId);
}
std::size_t ResolveVisibleTabIndex(
const UIEditorWorkspaceNode& node,
const UIEditorWorkspaceSession& session) {
if (node.kind != UIEditorWorkspaceNodeKind::TabStack || node.children.empty()) {
return node.selectedTabIndex;
}
if (node.selectedTabIndex < node.children.size()) {
const UIEditorWorkspaceNode& selectedChild = node.children[node.selectedTabIndex];
if (selectedChild.kind == UIEditorWorkspaceNodeKind::Panel &&
IsPanelOpenAndVisible(session, selectedChild.panel.panelId)) {
return node.selectedTabIndex;
}
}
for (std::size_t index = 0; index < node.children.size(); ++index) {
const UIEditorWorkspaceNode& child = node.children[index];
if (child.kind == UIEditorWorkspaceNodeKind::Panel &&
IsPanelOpenAndVisible(session, child.panel.panelId)) {
return index;
}
}
return node.children.size();
}
void CollectVisiblePanelsRecursive(
const UIEditorWorkspaceNode& node,
const UIEditorWorkspaceSession& session,
std::string_view activePanelId,
std::vector<UIEditorWorkspaceVisiblePanel>& outPanels) {
switch (node.kind) {
case UIEditorWorkspaceNodeKind::Panel: {
if (!IsPanelOpenAndVisible(session, node.panel.panelId)) {
return;
}
UIEditorWorkspaceVisiblePanel panel = {};
panel.panelId = node.panel.panelId;
panel.title = node.panel.title;
panel.active = node.panel.panelId == activePanelId;
panel.placeholder = node.panel.placeholder;
outPanels.push_back(std::move(panel));
return;
}
case UIEditorWorkspaceNodeKind::TabStack: {
const std::size_t resolvedIndex = ResolveVisibleTabIndex(node, session);
if (resolvedIndex < node.children.size()) {
CollectVisiblePanelsRecursive(
node.children[resolvedIndex],
session,
activePanelId,
outPanels);
}
return;
}
case UIEditorWorkspaceNodeKind::Split:
for (const UIEditorWorkspaceNode& child : node.children) {
CollectVisiblePanelsRecursive(child, session, activePanelId, outPanels);
}
return;
}
}
void NormalizeSessionStatesAgainstRegistry(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceSession& session) {
for (UIEditorPanelSessionState& state : session.panelStates) {
if (!state.open) {
state.visible = false;
}
const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, state.panelId);
if (descriptor == nullptr) {
continue;
}
if (!descriptor->canClose) {
state.open = true;
state.visible = true;
continue;
}
if (!descriptor->canHide && state.open) {
state.visible = true;
}
}
}
void NormalizeWorkspaceSession(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view preferredActivePanelId) {
NormalizeSessionStatesAgainstRegistry(panelRegistry, session);
std::string targetActivePanelId = {};
if (IsPanelSelectable(workspace, session, preferredActivePanelId)) {
targetActivePanelId = std::string(preferredActivePanelId);
} else if (IsPanelSelectable(workspace, session, workspace.activePanelId)) {
targetActivePanelId = workspace.activePanelId;
} else {
const std::vector<UIEditorWorkspaceVisiblePanel> visiblePanels =
CollectUIEditorWorkspaceVisiblePanels(workspace, session);
if (!visiblePanels.empty()) {
targetActivePanelId = visiblePanels.front().panelId;
}
}
if (targetActivePanelId.empty()) {
workspace.activePanelId.clear();
return;
}
TryActivateUIEditorWorkspacePanel(workspace, targetActivePanelId);
}
bool AreWorkspaceNodesEquivalent(
const UIEditorWorkspaceNode& lhs,
const UIEditorWorkspaceNode& rhs) {
if (lhs.kind != rhs.kind ||
lhs.nodeId != rhs.nodeId ||
lhs.splitAxis != rhs.splitAxis ||
lhs.splitRatio != rhs.splitRatio ||
lhs.selectedTabIndex != rhs.selectedTabIndex ||
lhs.panel.panelId != rhs.panel.panelId ||
lhs.panel.title != rhs.panel.title ||
lhs.panel.placeholder != rhs.panel.placeholder ||
lhs.children.size() != rhs.children.size()) {
return false;
}
for (std::size_t index = 0; index < lhs.children.size(); ++index) {
if (!AreWorkspaceNodesEquivalent(lhs.children[index], rhs.children[index])) {
return false;
}
}
return true;
}
bool AreWorkspaceModelsEquivalent(
const UIEditorWorkspaceModel& lhs,
const UIEditorWorkspaceModel& rhs) {
return lhs.activePanelId == rhs.activePanelId &&
AreWorkspaceNodesEquivalent(lhs.root, rhs.root);
}
bool AreWorkspaceSessionsEquivalent(
const UIEditorWorkspaceSession& lhs,
const UIEditorWorkspaceSession& rhs) {
if (lhs.panelStates.size() != rhs.panelStates.size()) {
return false;
}
for (std::size_t index = 0; index < lhs.panelStates.size(); ++index) {
const UIEditorPanelSessionState& lhsState = lhs.panelStates[index];
const UIEditorPanelSessionState& rhsState = rhs.panelStates[index];
if (lhsState.panelId != rhsState.panelId ||
lhsState.open != rhsState.open ||
lhsState.visible != rhsState.visible) {
return false;
}
}
return true;
}
} // namespace
UIEditorWorkspaceSession BuildDefaultUIEditorWorkspaceSession(
const UIEditorPanelRegistry& panelRegistry,
const UIEditorWorkspaceModel& workspace) {
UIEditorWorkspaceSession session = {};
std::vector<std::string> panelIds = {};
CollectWorkspacePanelIdsRecursive(workspace.root, panelIds);
session.panelStates.reserve(panelIds.size());
for (std::string& panelId : panelIds) {
UIEditorPanelSessionState state = {};
state.panelId = std::move(panelId);
session.panelStates.push_back(std::move(state));
}
NormalizeSessionStatesAgainstRegistry(panelRegistry, session);
return session;
}
const UIEditorPanelSessionState* FindUIEditorPanelSessionState(
const UIEditorWorkspaceSession& session,
std::string_view panelId) {
for (const UIEditorPanelSessionState& state : session.panelStates) {
if (state.panelId == panelId) {
return &state;
}
}
return nullptr;
}
UIEditorWorkspaceSessionValidationResult ValidateUIEditorWorkspaceSession(
const UIEditorPanelRegistry& panelRegistry,
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session) {
std::vector<std::string> workspacePanelIds = {};
CollectWorkspacePanelIdsRecursive(workspace.root, workspacePanelIds);
std::unordered_set<std::string> expectedPanelIds = {};
expectedPanelIds.insert(workspacePanelIds.begin(), workspacePanelIds.end());
std::unordered_set<std::string> seenPanelIds = {};
for (const UIEditorPanelSessionState& state : session.panelStates) {
if (!seenPanelIds.insert(state.panelId).second) {
return MakeValidationError(
UIEditorWorkspaceSessionValidationCode::DuplicatePanelId,
"Workspace session contains duplicated panel state '" + state.panelId + "'.");
}
if (!expectedPanelIds.contains(state.panelId)) {
return MakeValidationError(
UIEditorWorkspaceSessionValidationCode::UnknownPanelId,
"Workspace session state '" + state.panelId + "' is not present in the workspace tree.");
}
if (!state.open && state.visible) {
return MakeValidationError(
UIEditorWorkspaceSessionValidationCode::ClosedPanelVisible,
"Workspace session state '" + state.panelId + "' cannot be visible while closed.");
}
const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, state.panelId);
if (descriptor == nullptr) {
return MakeValidationError(
UIEditorWorkspaceSessionValidationCode::UnknownPanelId,
"Workspace session state '" + state.panelId + "' is missing from the panel registry.");
}
if (!descriptor->canClose && !state.open) {
return MakeValidationError(
UIEditorWorkspaceSessionValidationCode::NonCloseablePanelClosed,
"Workspace session state '" + state.panelId + "' cannot be closed.");
}
if (!descriptor->canHide && state.open && !state.visible) {
return MakeValidationError(
UIEditorWorkspaceSessionValidationCode::NonHideablePanelHidden,
"Workspace session state '" + state.panelId + "' cannot be hidden.");
}
}
for (const std::string& panelId : workspacePanelIds) {
if (!seenPanelIds.contains(panelId)) {
return MakeValidationError(
UIEditorWorkspaceSessionValidationCode::MissingPanelState,
"Workspace panel '" + panelId + "' is missing from the workspace session.");
}
}
if (!workspace.activePanelId.empty() &&
FindUIEditorWorkspaceActivePanel(workspace, session) == nullptr) {
return MakeValidationError(
UIEditorWorkspaceSessionValidationCode::InvalidActivePanelId,
"Active panel id '" + workspace.activePanelId + "' is missing, closed, or hidden.");
}
return {};
}
std::vector<UIEditorWorkspaceVisiblePanel> CollectUIEditorWorkspaceVisiblePanels(
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session) {
std::vector<UIEditorWorkspaceVisiblePanel> visiblePanels = {};
CollectVisiblePanelsRecursive(workspace.root, session, workspace.activePanelId, visiblePanels);
return visiblePanels;
}
const UIEditorWorkspacePanelState* FindUIEditorWorkspaceActivePanel(
const UIEditorWorkspaceModel& workspace,
const UIEditorWorkspaceSession& session) {
if (workspace.activePanelId.empty() ||
!IsPanelOpenAndVisible(session, workspace.activePanelId)) {
return nullptr;
}
const std::vector<UIEditorWorkspaceVisiblePanel> visiblePanels =
CollectUIEditorWorkspaceVisiblePanels(workspace, session);
for (const UIEditorWorkspaceVisiblePanel& panel : visiblePanels) {
if (panel.panelId == workspace.activePanelId) {
return FindPanelRecursive(workspace.root, workspace.activePanelId);
}
}
return nullptr;
}
bool TryOpenUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId) {
const UIEditorWorkspaceModel workspaceBefore = workspace;
const UIEditorWorkspaceSession sessionBefore = session;
UIEditorPanelSessionState* state = FindMutablePanelSessionState(session, panelId);
if (state == nullptr) {
return false;
}
state->open = true;
state->visible = true;
NormalizeWorkspaceSession(panelRegistry, workspace, session, panelId);
return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
!AreWorkspaceSessionsEquivalent(sessionBefore, session);
}
bool TryCloseUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId) {
const UIEditorWorkspaceModel workspaceBefore = workspace;
const UIEditorWorkspaceSession sessionBefore = session;
UIEditorPanelSessionState* state = FindMutablePanelSessionState(session, panelId);
const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, panelId);
if (state == nullptr || descriptor == nullptr || !descriptor->canClose) {
return false;
}
state->open = false;
state->visible = false;
NormalizeWorkspaceSession(panelRegistry, workspace, session, {});
return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
!AreWorkspaceSessionsEquivalent(sessionBefore, session);
}
bool TryShowUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId) {
const UIEditorWorkspaceModel workspaceBefore = workspace;
const UIEditorWorkspaceSession sessionBefore = session;
UIEditorPanelSessionState* state = FindMutablePanelSessionState(session, panelId);
const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, panelId);
if (state == nullptr || descriptor == nullptr || !state->open || !descriptor->canHide) {
return false;
}
state->visible = true;
NormalizeWorkspaceSession(panelRegistry, workspace, session, panelId);
return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
!AreWorkspaceSessionsEquivalent(sessionBefore, session);
}
bool TryHideUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId) {
const UIEditorWorkspaceModel workspaceBefore = workspace;
const UIEditorWorkspaceSession sessionBefore = session;
UIEditorPanelSessionState* state = FindMutablePanelSessionState(session, panelId);
const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, panelId);
if (state == nullptr || descriptor == nullptr || !state->open || !descriptor->canHide) {
return false;
}
state->visible = false;
NormalizeWorkspaceSession(panelRegistry, workspace, session, {});
return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
!AreWorkspaceSessionsEquivalent(sessionBefore, session);
}
bool TryActivateUIEditorWorkspacePanel(
const UIEditorPanelRegistry& panelRegistry,
UIEditorWorkspaceModel& workspace,
UIEditorWorkspaceSession& session,
std::string_view panelId) {
const UIEditorWorkspaceModel workspaceBefore = workspace;
const UIEditorWorkspaceSession sessionBefore = session;
if (!IsPanelSelectable(workspace, session, panelId)) {
return false;
}
NormalizeWorkspaceSession(panelRegistry, workspace, session, panelId);
return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
!AreWorkspaceSessionsEquivalent(sessionBefore, session);
}
} // namespace XCEngine::NewEditor

View File

@@ -1,15 +1,7 @@
<Theme name="EditorShellTheme"> <Theme name="EditorShellTheme">
<Tokens> <Tokens>
<Color name="color.bg.workspace" value="#1C1C1C" /> <Color name="color.bg.workspace" value="#16181C" />
<Color name="color.bg.panel" value="#292929" /> <Spacing name="space.shell" value="14" />
<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> </Tokens>
<Widgets> <Widgets>
@@ -17,16 +9,5 @@
<Property name="background" value="color.bg.workspace" /> <Property name="background" value="color.bg.workspace" />
<Property name="padding" value="space.shell" /> <Property name="padding" value="space.shell" />
</Widget> </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> </Widgets>
</Theme> </Theme>

View File

@@ -1,8 +1,9 @@
<View <View
name="NewEditorShell" name="NewEditorShell"
theme="../themes/editor_shell.xctheme"> theme="../themes/editor_shell.xctheme"
style="EditorWorkspace">
<Column <Column
id="editor-shell-root" id="editor-foundation-root"
width="fill" width="fill"
height="fill" /> height="fill" />
</View> </View>

View File

@@ -2,8 +2,8 @@ cmake_minimum_required(VERSION 3.15)
project(XCEngine_CoreUITests) project(XCEngine_CoreUITests)
add_subdirectory(unit)
add_subdirectory(integration) add_subdirectory(integration)
add_subdirectory(unit)
add_custom_target(core_ui_unit_tests add_custom_target(core_ui_unit_tests
DEPENDS DEPENDS

View File

@@ -1 +1,13 @@
add_custom_target(core_ui_integration_tests) add_subdirectory(shared)
add_subdirectory(input)
add_subdirectory(layout)
add_subdirectory(style)
add_subdirectory(text)
add_custom_target(core_ui_integration_tests
DEPENDS
core_ui_input_integration_tests
core_ui_layout_integration_tests
core_ui_style_integration_tests
core_ui_text_integration_tests
)

View File

@@ -1,8 +1,24 @@
# Core UI Integration Notes # Core UI Integration Validation
The core XCUI lane currently validates shared primitives through automated unit tests. This directory contains the manual XCUI validation system for shared Core primitives.
Interactive validation belongs to: Structure:
- `tests/UI/Runtime/integration/` for game/runtime UI - `shared/`: shared host, native renderer, screenshot helper, scenario registry
- `tests/UI/Editor/integration/` for editor UI - `input/`: shared input validation category
- `layout/`: shared layout validation category
- `style/`: shared theme token and style resolution validation category
- `text/`: shared UTF-8 text rendering and textInput focus marker 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 core_ui_integration_tests
```

View File

@@ -0,0 +1,12 @@
add_subdirectory(keyboard_focus)
add_subdirectory(pointer_states)
add_subdirectory(scroll_view)
add_subdirectory(shortcut_scope)
add_custom_target(core_ui_input_integration_tests
DEPENDS
core_ui_input_keyboard_focus_validation
core_ui_input_pointer_states_validation
core_ui_input_scroll_view_validation
core_ui_input_shortcut_scope_validation
)

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,15 @@
<View <View
name="EditorInputKeyboardFocus" name="CoreInputKeyboardFocus"
theme="../../shared/themes/editor_validation.xctheme"> theme="../../shared/themes/core_validation.xctheme">
<Column padding="24" gap="16"> <Column padding="24" gap="16">
<Card <Card
title="Editor Validation | Keyboard Focus" title="Core Validation | Keyboard Focus"
subtitle="当前批次Tab 焦点遍历 | Enter / Space 激活" subtitle="当前批次Tab 焦点遍历 | Enter / Space 激活"
tone="accent" tone="accent"
height="90"> height="90">
<Column gap="8"> <Column gap="8">
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" /> <Text text="这是 Core 层验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查键盘焦点和激活,不混入复杂 editor 面板。" /> <Text text="这一轮只检查键盘焦点和激活,不混入上层业务面板。" />
</Column> </Column>
</Card> </Card>

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
<View <View
name="EditorInputPointerStates" name="CoreInputPointerStates"
theme="../../shared/themes/editor_validation.xctheme"> theme="../../shared/themes/core_validation.xctheme">
<Column padding="24" gap="16"> <Column padding="24" gap="16">
<Card <Card
title="Editor Validation | Pointer States" title="Core Validation | Pointer States"
subtitle="当前批次:鼠标 hover / focus / active / capture" subtitle="当前批次:鼠标 hover / focus / active / capture"
tone="accent" tone="accent"
height="90"> height="90">
<Column gap="8"> <Column gap="8">
<Text text="这是 editor 侧验证场景,不承载 runtime 游戏 UI。" /> <Text text="这是 Core 层验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查鼠标输入状态,不混入别的控件实验。" /> <Text text="这一轮只检查鼠标输入状态,不混入别的控件实验。" />
</Column> </Column>
</Card> </Card>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<View <View
name="EditorInputScrollView" name="CoreInputScrollView"
theme="../../shared/themes/editor_validation.xctheme"> theme="../../shared/themes/core_validation.xctheme">
<Column padding="20" gap="12"> <Column padding="20" gap="12">
<Card <Card
title="功能ScrollView 滚动 / clip / overflow" title="功能ScrollView 滚动 / clip / overflow"
@@ -11,7 +11,7 @@
<Text text="1. 把鼠标放到下方日志区内滚动滚轮:内容应上下移动,右下角 Scroll target 应落到 validation-scroll。" /> <Text text="1. 把鼠标放到下方日志区内滚动滚轮:内容应上下移动,右下角 Scroll target 应落到 validation-scroll。" />
<Text text="2. 连续向下滚到末尾再继续滚Offset 应被 clampResult 应显示 Scroll delta clamped to current offset。" /> <Text text="2. 连续向下滚到末尾再继续滚Offset 应被 clampResult 应显示 Scroll delta clamped to current offset。" />
<Text text="3. 把鼠标移到日志区外再滚动日志位置不应变化Result 应显示 No hovered ScrollView。" /> <Text text="3. 把鼠标移到日志区外再滚动日志位置不应变化Result 应显示 No hovered ScrollView。" />
<Text text="4. 这个场景只验证 ScrollView 基础能力,不验证 editor 业务面板。" /> <Text text="4. 这个场景只验证 ScrollView 基础能力,不验证上层业务面板。" />
</Column> </Column>
</Card> </Card>

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
<View <View
name="EditorInputShortcutScope" name="CoreInputShortcutScope"
theme="../../shared/themes/editor_validation.xctheme" theme="../../shared/themes/core_validation.xctheme"
shortcut="Ctrl+P" shortcut="Ctrl+P"
shortcutCommand="global.command" shortcutCommand="global.command"
shortcutScope="global"> shortcutScope="global">
<Column padding="20" gap="12"> <Column padding="20" gap="12">
<Card <Card
title="Editor Validation | Shortcut Scope" title="Core Validation | Shortcut Scope"
subtitle="验证功能:Editor shortcut scope 路由与 text input suppression" subtitle="验证功能:Core shortcut scope 路由与 text input suppression"
tone="accent" tone="accent"
height="100"> height="100">
<Column gap="6"> <Column gap="6">

View File

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

View File

@@ -0,0 +1,10 @@
add_subdirectory(splitter_resize)
add_subdirectory(tab_strip_selection)
add_subdirectory(workspace_compose)
add_custom_target(core_ui_layout_integration_tests
DEPENDS
core_ui_layout_splitter_resize_validation
core_ui_layout_tab_strip_selection_validation
core_ui_layout_workspace_compose_validation
)

View File

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

View File

@@ -1,6 +1,6 @@
<View <View
name="EditorSplitterResizeValidation" name="CoreSplitterResizeValidation"
theme="../../shared/themes/editor_validation.xctheme"> theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12"> <Column width="fill" height="fill" padding="20" gap="12">
<Card <Card
title="功能Splitter / pane resize" title="功能Splitter / pane resize"

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<View <View
name="EditorTabStripSelectionValidation" name="CoreTabStripSelectionValidation"
theme="../../shared/themes/editor_validation.xctheme"> theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12"> <Column width="fill" height="fill" padding="20" gap="12">
<Card <Card
title="功能TabStrip 选择切换" title="功能TabStrip 选择切换"
@@ -11,12 +11,12 @@
<Text text="1. 点击 Scene / Console / Inspector 任一 tab下方内容区应立即切换旧内容不应继续显示。" /> <Text text="1. 点击 Scene / Console / Inspector 任一 tab下方内容区应立即切换旧内容不应继续显示。" />
<Text text="2. 先点击一个 tab 让它获得 focus再按 Left / Right / Home / Endselected tab 应变化。" /> <Text text="2. 先点击一个 tab 让它获得 focus再按 Left / Right / Home / Endselected tab 应变化。" />
<Text text="3. 右下角 Result 正常应显示 Tab selected 或 Tab navigatedFocused 应落在当前 tab。" /> <Text text="3. 右下角 Result 正常应显示 Tab selected 或 Tab navigatedFocused 应落在当前 tab。" />
<Text text="4. 这个场景只检查 TabStrip 基础能力,不检查 editor 业务面板。" /> <Text text="4. 这个场景只检查 TabStrip 基础能力,不检查上层业务面板。" />
</Column> </Column>
</Card> </Card>
<TabStrip <TabStrip
id="editor-workspace-tabs" id="core-workspace-tabs"
tabHeaderHeight="34" tabHeaderHeight="34"
tabMinWidth="96" tabMinWidth="96"
height="fill"> height="fill">

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.layout.tab_strip_selection");
}

View File

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

View File

@@ -1,10 +1,10 @@
<View <View
name="EditorWorkspaceComposeValidation" name="CoreWorkspaceComposeValidation"
theme="../../shared/themes/editor_validation.xctheme"> theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12"> <Column width="fill" height="fill" padding="20" gap="12">
<Card <Card
title="功能Workspace compose" title="功能Workspace compose"
subtitle="只检查 editor 工作区的 split + tab + placeholder 组合,不检查任何业务面板" subtitle="只检查共享工作区组合原语的 split + tab + placeholder不检查任何业务面板"
tone="accent" tone="accent"
height="156"> height="156">
<Column gap="6"> <Column gap="6">

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.layout.workspace_compose");
}

View File

@@ -0,0 +1,61 @@
file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH)
add_library(core_ui_validation_registry STATIC
src/CoreValidationScenario.cpp
)
target_include_directories(core_ui_validation_registry
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_validation_registry
PUBLIC
XCENGINE_CORE_UI_TESTS_REPO_ROOT="${XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(core_ui_validation_registry PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_validation_registry PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_validation_registry
PUBLIC
XCEngine
)
add_library(core_ui_integration_host STATIC
src/AutoScreenshot.cpp
src/Application.cpp
src/NativeRenderer.cpp
)
target_include_directories(core_ui_integration_host
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_SOURCE_DIR}/engine/include
)
target_compile_definitions(core_ui_integration_host
PUBLIC
UNICODE
_UNICODE
XCENGINE_CORE_UI_TESTS_REPO_ROOT="${XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH}"
)
if(MSVC)
target_compile_options(core_ui_integration_host PRIVATE /utf-8 /FS)
set_property(TARGET core_ui_integration_host PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>DLL")
endif()
target_link_libraries(core_ui_integration_host
PUBLIC
core_ui_validation_registry
XCEngine
d2d1.lib
dwrite.lib
windowscodecs.lib
)

View File

@@ -0,0 +1,802 @@
#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_CORE_UI_TESTS_REPO_ROOT
#define XCENGINE_CORE_UI_TESTS_REPO_ROOT "."
#endif
namespace XCEngine::Tests::CoreUI {
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"XCUICoreValidationHost";
constexpr const wchar_t* kWindowTitle = L"XCUI Core 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_CORE_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 CoreValidationScenario* initialScenario = m_requestedScenarioId.empty()
? &GetDefaultCoreValidationScenario()
: FindCoreValidationScenario(m_requestedScenarioId);
if (initialScenario == nullptr) {
initialScenario = &GetDefaultCoreValidationScenario();
}
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
: "Core UI Validation";
m_runtimeError = frame.errorMessage;
}
if (drawData.Empty()) {
m_runtimeStatus = "Core 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 CoreValidationScenario* scenario = m_requestedScenarioId.empty()
? &GetDefaultCoreValidationScenario()
: FindCoreValidationScenario(m_requestedScenarioId);
if (scenario == nullptr) {
scenario = &GetDefaultCoreValidationScenario();
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 : "Core 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();
const auto& scrollDebug = m_documentHost.GetScrollDebugSnapshot();
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));
}
detailLines.push_back(
"Scroll target | Primary: " +
ExtractStateKeyTail(scrollDebug.lastTargetStateKey) +
" | " +
ExtractStateKeyTail(scrollDebug.primaryTargetStateKey));
detailLines.push_back(
"Scroll offset B/A: " +
FormatFloat(scrollDebug.lastOffsetBefore) +
" -> " +
FormatFloat(scrollDebug.lastOffsetAfter) +
" | overflow " +
FormatFloat(scrollDebug.lastOverflow));
detailLines.push_back(
"Scroll H/T: " +
std::to_string(scrollDebug.handledWheelEventCount) +
"/" +
std::to_string(scrollDebug.totalWheelEventCount) +
" | " +
(scrollDebug.lastResult.empty() ? std::string("n/a") : scrollDebug.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("Core 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() ? "Core 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 RunCoreUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId) {
Application application(std::move(requestedScenarioId));
return application.Run(hInstance, nCmdShow);
}
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,83 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include "AutoScreenshot.h"
#include "CoreValidationScenario.h"
#include "InputModifierTracker.h"
#include "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::CoreUI {
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;
Host::NativeRenderer m_renderer;
Host::AutoScreenshotController m_autoScreenshot;
::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost;
::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer;
::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {};
const CoreValidationScenario* 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 = {};
Host::InputModifierTracker m_inputModifierTracker = {};
bool m_trackingMouseLeave = false;
bool m_useStructuredScreen = false;
std::string m_runtimeStatus = {};
std::string m_runtimeError = {};
};
int RunCoreUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId = {});
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,165 @@
#include "AutoScreenshot.h"
#include "NativeRenderer.h"
#include <chrono>
#include <cctype>
#include <cstdio>
#include <sstream>
#include <system_error>
namespace XCEngine::Tests::CoreUI::Host {
void AutoScreenshotController::Initialize(const std::filesystem::path& captureRoot) {
m_captureRoot = captureRoot.lexically_normal();
m_historyRoot = (m_captureRoot / "history").lexically_normal();
m_latestCapturePath = (m_captureRoot / "latest.png").lexically_normal();
m_captureCount = 0;
m_capturePending = false;
m_pendingReason.clear();
m_lastCaptureSummary.clear();
m_lastCaptureError.clear();
}
void AutoScreenshotController::Shutdown() {
m_capturePending = false;
m_pendingReason.clear();
}
void AutoScreenshotController::RequestCapture(std::string reason) {
m_pendingReason = reason.empty() ? "capture" : std::move(reason);
m_capturePending = true;
}
void AutoScreenshotController::CaptureIfRequested(
NativeRenderer& renderer,
const ::XCEngine::UI::UIDrawData& drawData,
unsigned int width,
unsigned int height,
bool framePresented) {
if (!m_capturePending || !framePresented || drawData.Empty() || width == 0u || height == 0u) {
return;
}
std::error_code errorCode = {};
std::filesystem::create_directories(m_captureRoot, errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to create screenshot directory: " + m_captureRoot.string();
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
std::filesystem::create_directories(m_historyRoot, errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to create screenshot directory: " + m_historyRoot.string();
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
std::string captureError = {};
const std::filesystem::path historyPath = BuildHistoryCapturePath(m_pendingReason);
if (!renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) {
m_lastCaptureError = std::move(captureError);
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
errorCode.clear();
std::filesystem::copy_file(
historyPath,
m_latestCapturePath,
std::filesystem::copy_options::overwrite_existing,
errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to update latest screenshot: " + m_latestCapturePath.string();
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
++m_captureCount;
m_lastCaptureError.clear();
m_lastCaptureSummary = "Shot: latest.png | " + historyPath.filename().string();
m_capturePending = false;
m_pendingReason.clear();
}
bool AutoScreenshotController::HasPendingCapture() const {
return m_capturePending;
}
const std::filesystem::path& AutoScreenshotController::GetLatestCapturePath() const {
return m_latestCapturePath;
}
const std::string& AutoScreenshotController::GetLastCaptureSummary() const {
return m_lastCaptureSummary;
}
const std::string& AutoScreenshotController::GetLastCaptureError() const {
return m_lastCaptureError;
}
std::filesystem::path AutoScreenshotController::BuildHistoryCapturePath(std::string_view reason) const {
std::ostringstream filename;
filename << BuildTimestampString()
<< '_'
<< (m_captureCount + 1u)
<< '_'
<< SanitizeReason(reason)
<< ".png";
return (m_historyRoot / filename.str()).lexically_normal();
}
std::string AutoScreenshotController::BuildTimestampString() {
const auto now = std::chrono::system_clock::now();
const std::time_t currentTime = std::chrono::system_clock::to_time_t(now);
std::tm localTime = {};
localtime_s(&localTime, &currentTime);
char buffer[32] = {};
std::snprintf(
buffer,
sizeof(buffer),
"%04d%02d%02d_%02d%02d%02d",
localTime.tm_year + 1900,
localTime.tm_mon + 1,
localTime.tm_mday,
localTime.tm_hour,
localTime.tm_min,
localTime.tm_sec);
return buffer;
}
std::string AutoScreenshotController::SanitizeReason(std::string_view reason) {
std::string sanitized = {};
sanitized.reserve(reason.size());
bool lastWasSeparator = false;
for (const unsigned char value : reason) {
if (std::isalnum(value)) {
sanitized.push_back(static_cast<char>(std::tolower(value)));
lastWasSeparator = false;
continue;
}
if (!lastWasSeparator) {
sanitized.push_back('_');
lastWasSeparator = true;
}
}
while (!sanitized.empty() && sanitized.front() == '_') {
sanitized.erase(sanitized.begin());
}
while (!sanitized.empty() && sanitized.back() == '_') {
sanitized.pop_back();
}
return sanitized.empty() ? "capture" : sanitized;
}
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,52 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEngine/UI/DrawData.h>
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
namespace XCEngine::Tests::CoreUI::Host {
class NativeRenderer;
class AutoScreenshotController {
public:
void Initialize(const std::filesystem::path& captureRoot);
void Shutdown();
void RequestCapture(std::string reason);
void CaptureIfRequested(
NativeRenderer& renderer,
const ::XCEngine::UI::UIDrawData& drawData,
unsigned int width,
unsigned int height,
bool framePresented);
bool HasPendingCapture() const;
const std::filesystem::path& GetLatestCapturePath() const;
const std::string& GetLastCaptureSummary() const;
const std::string& GetLastCaptureError() const;
private:
std::filesystem::path BuildHistoryCapturePath(std::string_view reason) const;
static std::string BuildTimestampString();
static std::string SanitizeReason(std::string_view reason);
std::filesystem::path m_captureRoot = {};
std::filesystem::path m_historyRoot = {};
std::filesystem::path m_latestCapturePath = {};
std::string m_pendingReason = {};
std::string m_lastCaptureSummary = {};
std::string m_lastCaptureError = {};
std::uint64_t m_captureCount = 0;
bool m_capturePending = false;
};
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,130 @@
#include "CoreValidationScenario.h"
#include <array>
#ifndef XCENGINE_CORE_UI_TESTS_REPO_ROOT
#define XCENGINE_CORE_UI_TESTS_REPO_ROOT "."
#endif
namespace XCEngine::Tests::CoreUI {
namespace {
namespace fs = std::filesystem;
fs::path RepoRootPath() {
std::string root = XCENGINE_CORE_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<CoreValidationScenario, 9>& GetCoreValidationScenarios() {
static const std::array<CoreValidationScenario, 9> scenarios = { {
{
"core.input.keyboard_focus",
UIValidationDomain::Core,
"input",
"Core Input | Keyboard Focus",
RepoRelative("tests/UI/Core/integration/input/keyboard_focus/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/keyboard_focus/captures")
},
{
"core.input.pointer_states",
UIValidationDomain::Core,
"input",
"Core Input | Pointer States",
RepoRelative("tests/UI/Core/integration/input/pointer_states/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/pointer_states/captures")
},
{
"core.input.scroll_view",
UIValidationDomain::Core,
"input",
"Core Input | Scroll View",
RepoRelative("tests/UI/Core/integration/input/scroll_view/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/scroll_view/captures")
},
{
"core.input.shortcut_scope",
UIValidationDomain::Core,
"input",
"Core Input | Shortcut Scope",
RepoRelative("tests/UI/Core/integration/input/shortcut_scope/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/input/shortcut_scope/captures")
},
{
"core.layout.splitter_resize",
UIValidationDomain::Core,
"layout",
"Core Layout | Splitter Resize",
RepoRelative("tests/UI/Core/integration/layout/splitter_resize/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/layout/splitter_resize/captures")
},
{
"core.layout.tab_strip_selection",
UIValidationDomain::Core,
"layout",
"Core Layout | TabStrip Selection",
RepoRelative("tests/UI/Core/integration/layout/tab_strip_selection/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/layout/tab_strip_selection/captures")
},
{
"core.layout.workspace_compose",
UIValidationDomain::Core,
"layout",
"Core Layout | Workspace Compose",
RepoRelative("tests/UI/Core/integration/layout/workspace_compose/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/layout/workspace_compose/captures")
},
{
"core.style.theme_tokens",
UIValidationDomain::Core,
"style",
"Core Style | Theme Tokens",
RepoRelative("tests/UI/Core/integration/style/theme_tokens/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/style/theme_tokens/captures")
},
{
"core.text.utf8_focus_surface",
UIValidationDomain::Core,
"text",
"Core Text | UTF-8 Focus Surface",
RepoRelative("tests/UI/Core/integration/text/utf8_focus_surface/View.xcui"),
RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
RepoRelative("tests/UI/Core/integration/text/utf8_focus_surface/captures")
}
} };
return scenarios;
}
} // namespace
const CoreValidationScenario& GetDefaultCoreValidationScenario() {
return GetCoreValidationScenarios().front();
}
const CoreValidationScenario* FindCoreValidationScenario(std::string_view id) {
for (const CoreValidationScenario& scenario : GetCoreValidationScenarios()) {
if (scenario.id == id) {
return &scenario;
}
}
return nullptr;
}
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,26 @@
#pragma once
#include <filesystem>
#include <string>
#include <string_view>
namespace XCEngine::Tests::CoreUI {
enum class UIValidationDomain : unsigned char {
Core = 0
};
struct CoreValidationScenario {
std::string id = {};
UIValidationDomain domain = UIValidationDomain::Core;
std::string categoryId = {};
std::string displayName = {};
std::filesystem::path documentPath = {};
std::filesystem::path themePath = {};
std::filesystem::path captureRootPath = {};
};
const CoreValidationScenario& GetDefaultCoreValidationScenario();
const CoreValidationScenario* FindCoreValidationScenario(std::string_view id);
} // namespace XCEngine::Tests::CoreUI

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::Tests::CoreUI::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::Tests::CoreUI::Host

View File

@@ -0,0 +1,485 @@
#include "NativeRenderer.h"
#include <algorithm>
#include <cmath>
#include <filesystem>
namespace XCEngine::Tests::CoreUI::Host {
namespace {
D2D1_RECT_F ToD2DRect(const ::XCEngine::UI::UIRect& rect) {
return D2D1::RectF(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height);
}
std::string HrToString(const char* operation, HRESULT hr) {
char buffer[128] = {};
sprintf_s(buffer, "%s failed with hr=0x%08X.", operation, static_cast<unsigned int>(hr));
return buffer;
}
} // namespace
bool NativeRenderer::Initialize(HWND hwnd) {
Shutdown();
if (hwnd == nullptr) {
return false;
}
m_hwnd = hwnd;
if (FAILED(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, m_d2dFactory.ReleaseAndGetAddressOf()))) {
Shutdown();
return false;
}
if (FAILED(DWriteCreateFactory(
DWRITE_FACTORY_TYPE_SHARED,
__uuidof(IDWriteFactory),
reinterpret_cast<IUnknown**>(m_dwriteFactory.ReleaseAndGetAddressOf())))) {
Shutdown();
return false;
}
return EnsureRenderTarget();
}
void NativeRenderer::Shutdown() {
m_textFormats.clear();
m_solidBrush.Reset();
m_renderTarget.Reset();
m_wicFactory.Reset();
m_dwriteFactory.Reset();
m_d2dFactory.Reset();
if (m_wicComInitialized) {
CoUninitialize();
m_wicComInitialized = false;
}
m_hwnd = nullptr;
}
void NativeRenderer::Resize(UINT width, UINT height) {
if (!m_renderTarget || width == 0 || height == 0) {
return;
}
const HRESULT hr = m_renderTarget->Resize(D2D1::SizeU(width, height));
if (hr == D2DERR_RECREATE_TARGET) {
DiscardRenderTarget();
}
}
bool NativeRenderer::Render(const ::XCEngine::UI::UIDrawData& drawData) {
if (!EnsureRenderTarget()) {
return false;
}
const bool rendered = RenderToTarget(*m_renderTarget.Get(), *m_solidBrush.Get(), drawData);
const HRESULT hr = m_renderTarget->EndDraw();
if (hr == D2DERR_RECREATE_TARGET) {
DiscardRenderTarget();
return false;
}
return rendered && SUCCEEDED(hr);
}
bool NativeRenderer::CaptureToPng(
const ::XCEngine::UI::UIDrawData& drawData,
UINT width,
UINT height,
const std::filesystem::path& outputPath,
std::string& outError) {
outError.clear();
if (width == 0 || height == 0) {
outError = "CaptureToPng rejected an empty render size.";
return false;
}
if (!m_d2dFactory || !m_dwriteFactory) {
outError = "CaptureToPng requires an initialized NativeRenderer.";
return false;
}
if (!EnsureWicFactory(outError)) {
return false;
}
std::error_code errorCode = {};
std::filesystem::create_directories(outputPath.parent_path(), errorCode);
if (errorCode) {
outError = "Failed to create screenshot directory: " + outputPath.parent_path().string();
return false;
}
Microsoft::WRL::ComPtr<IWICBitmap> bitmap;
HRESULT hr = m_wicFactory->CreateBitmap(
width,
height,
GUID_WICPixelFormat32bppPBGRA,
WICBitmapCacheOnLoad,
bitmap.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("IWICImagingFactory::CreateBitmap", hr);
return false;
}
const D2D1_RENDER_TARGET_PROPERTIES renderTargetProperties = D2D1::RenderTargetProperties(
D2D1_RENDER_TARGET_TYPE_DEFAULT,
D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED));
Microsoft::WRL::ComPtr<ID2D1RenderTarget> offscreenRenderTarget;
hr = m_d2dFactory->CreateWicBitmapRenderTarget(
bitmap.Get(),
renderTargetProperties,
offscreenRenderTarget.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("ID2D1Factory::CreateWicBitmapRenderTarget", hr);
return false;
}
Microsoft::WRL::ComPtr<ID2D1SolidColorBrush> offscreenBrush;
hr = offscreenRenderTarget->CreateSolidColorBrush(
D2D1::ColorF(1.0f, 1.0f, 1.0f, 1.0f),
offscreenBrush.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("ID2D1RenderTarget::CreateSolidColorBrush", hr);
return false;
}
const bool rendered = RenderToTarget(*offscreenRenderTarget.Get(), *offscreenBrush.Get(), drawData);
hr = offscreenRenderTarget->EndDraw();
if (!rendered || FAILED(hr)) {
outError = HrToString("ID2D1RenderTarget::EndDraw", hr);
return false;
}
const std::wstring wideOutputPath = outputPath.wstring();
Microsoft::WRL::ComPtr<IWICStream> stream;
hr = m_wicFactory->CreateStream(stream.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("IWICImagingFactory::CreateStream", hr);
return false;
}
hr = stream->InitializeFromFilename(wideOutputPath.c_str(), GENERIC_WRITE);
if (FAILED(hr)) {
outError = HrToString("IWICStream::InitializeFromFilename", hr);
return false;
}
Microsoft::WRL::ComPtr<IWICBitmapEncoder> encoder;
hr = m_wicFactory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("IWICImagingFactory::CreateEncoder", hr);
return false;
}
hr = encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache);
if (FAILED(hr)) {
outError = HrToString("IWICBitmapEncoder::Initialize", hr);
return false;
}
Microsoft::WRL::ComPtr<IWICBitmapFrameEncode> frame;
Microsoft::WRL::ComPtr<IPropertyBag2> propertyBag;
hr = encoder->CreateNewFrame(frame.ReleaseAndGetAddressOf(), propertyBag.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
outError = HrToString("IWICBitmapEncoder::CreateNewFrame", hr);
return false;
}
hr = frame->Initialize(propertyBag.Get());
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::Initialize", hr);
return false;
}
hr = frame->SetSize(width, height);
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::SetSize", hr);
return false;
}
WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat32bppPBGRA;
hr = frame->SetPixelFormat(&pixelFormat);
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::SetPixelFormat", hr);
return false;
}
hr = frame->WriteSource(bitmap.Get(), nullptr);
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::WriteSource", hr);
return false;
}
hr = frame->Commit();
if (FAILED(hr)) {
outError = HrToString("IWICBitmapFrameEncode::Commit", hr);
return false;
}
hr = encoder->Commit();
if (FAILED(hr)) {
outError = HrToString("IWICBitmapEncoder::Commit", hr);
return false;
}
return true;
}
bool NativeRenderer::EnsureRenderTarget() {
if (!m_hwnd || !m_d2dFactory || !m_dwriteFactory) {
return false;
}
return CreateDeviceResources();
}
bool NativeRenderer::EnsureWicFactory(std::string& outError) {
outError.clear();
if (m_wicFactory) {
return true;
}
const HRESULT initHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
if (FAILED(initHr) && initHr != RPC_E_CHANGED_MODE) {
outError = HrToString("CoInitializeEx", initHr);
return false;
}
if (SUCCEEDED(initHr)) {
m_wicComInitialized = true;
}
const HRESULT factoryHr = CoCreateInstance(
CLSID_WICImagingFactory,
nullptr,
CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(m_wicFactory.ReleaseAndGetAddressOf()));
if (FAILED(factoryHr)) {
outError = HrToString("CoCreateInstance(CLSID_WICImagingFactory)", factoryHr);
return false;
}
return true;
}
void NativeRenderer::DiscardRenderTarget() {
m_solidBrush.Reset();
m_renderTarget.Reset();
}
bool NativeRenderer::CreateDeviceResources() {
if (m_renderTarget) {
return true;
}
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const UINT width = static_cast<UINT>((std::max)(clientRect.right - clientRect.left, 1L));
const UINT height = static_cast<UINT>((std::max)(clientRect.bottom - clientRect.top, 1L));
const D2D1_RENDER_TARGET_PROPERTIES renderTargetProps = D2D1::RenderTargetProperties();
const D2D1_HWND_RENDER_TARGET_PROPERTIES hwndProps = D2D1::HwndRenderTargetProperties(
m_hwnd,
D2D1::SizeU(width, height));
if (FAILED(m_d2dFactory->CreateHwndRenderTarget(
renderTargetProps,
hwndProps,
m_renderTarget.ReleaseAndGetAddressOf()))) {
return false;
}
if (FAILED(m_renderTarget->CreateSolidColorBrush(
D2D1::ColorF(1.0f, 1.0f, 1.0f, 1.0f),
m_solidBrush.ReleaseAndGetAddressOf()))) {
DiscardRenderTarget();
return false;
}
m_renderTarget->SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE);
return true;
}
bool NativeRenderer::RenderToTarget(
ID2D1RenderTarget& renderTarget,
ID2D1SolidColorBrush& solidBrush,
const ::XCEngine::UI::UIDrawData& drawData) {
renderTarget.SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE);
renderTarget.BeginDraw();
renderTarget.Clear(D2D1::ColorF(0.04f, 0.05f, 0.06f, 1.0f));
std::vector<D2D1_RECT_F> clipStack = {};
for (const ::XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
for (const ::XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
RenderCommand(renderTarget, solidBrush, command, clipStack);
}
}
while (!clipStack.empty()) {
renderTarget.PopAxisAlignedClip();
clipStack.pop_back();
}
return true;
}
void NativeRenderer::RenderCommand(
ID2D1RenderTarget& renderTarget,
ID2D1SolidColorBrush& solidBrush,
const ::XCEngine::UI::UIDrawCommand& command,
std::vector<D2D1_RECT_F>& clipStack) {
solidBrush.SetColor(ToD2DColor(command.color));
switch (command.type) {
case ::XCEngine::UI::UIDrawCommandType::FilledRect: {
const D2D1_RECT_F rect = ToD2DRect(command.rect);
if (command.rounding > 0.0f) {
renderTarget.FillRoundedRectangle(
D2D1::RoundedRect(rect, command.rounding, command.rounding),
&solidBrush);
} else {
renderTarget.FillRectangle(rect, &solidBrush);
}
break;
}
case ::XCEngine::UI::UIDrawCommandType::RectOutline: {
const D2D1_RECT_F rect = ToD2DRect(command.rect);
const float thickness = command.thickness > 0.0f ? command.thickness : 1.0f;
if (command.rounding > 0.0f) {
renderTarget.DrawRoundedRectangle(
D2D1::RoundedRect(rect, command.rounding, command.rounding),
&solidBrush,
thickness);
} else {
renderTarget.DrawRectangle(rect, &solidBrush, thickness);
}
break;
}
case ::XCEngine::UI::UIDrawCommandType::Text: {
if (command.text.empty()) {
break;
}
const float fontSize = command.fontSize > 0.0f ? command.fontSize : 16.0f;
IDWriteTextFormat* textFormat = GetTextFormat(fontSize);
if (textFormat == nullptr) {
break;
}
const std::wstring text = Utf8ToWide(command.text);
if (text.empty()) {
break;
}
const D2D1_SIZE_F targetSize = renderTarget.GetSize();
const D2D1_RECT_F layoutRect = D2D1::RectF(
command.position.x,
command.position.y,
targetSize.width,
command.position.y + fontSize * 1.8f);
renderTarget.DrawTextW(
text.c_str(),
static_cast<UINT32>(text.size()),
textFormat,
layoutRect,
&solidBrush,
D2D1_DRAW_TEXT_OPTIONS_CLIP,
DWRITE_MEASURING_MODE_NATURAL);
break;
}
case ::XCEngine::UI::UIDrawCommandType::Image: {
if (!command.texture.IsValid()) {
break;
}
const D2D1_RECT_F rect = ToD2DRect(command.rect);
renderTarget.DrawRectangle(rect, &solidBrush, 1.0f);
break;
}
case ::XCEngine::UI::UIDrawCommandType::PushClipRect: {
const D2D1_RECT_F rect = ToD2DRect(command.rect);
renderTarget.PushAxisAlignedClip(rect, D2D1_ANTIALIAS_MODE_PER_PRIMITIVE);
clipStack.push_back(rect);
break;
}
case ::XCEngine::UI::UIDrawCommandType::PopClipRect: {
if (!clipStack.empty()) {
renderTarget.PopAxisAlignedClip();
clipStack.pop_back();
}
break;
}
default:
break;
}
}
IDWriteTextFormat* NativeRenderer::GetTextFormat(float fontSize) {
if (!m_dwriteFactory) {
return nullptr;
}
const int key = static_cast<int>(std::lround(fontSize * 10.0f));
const auto found = m_textFormats.find(key);
if (found != m_textFormats.end()) {
return found->second.Get();
}
Microsoft::WRL::ComPtr<IDWriteTextFormat> textFormat;
const HRESULT hr = m_dwriteFactory->CreateTextFormat(
L"Segoe UI",
nullptr,
DWRITE_FONT_WEIGHT_REGULAR,
DWRITE_FONT_STYLE_NORMAL,
DWRITE_FONT_STRETCH_NORMAL,
fontSize,
L"",
textFormat.ReleaseAndGetAddressOf());
if (FAILED(hr)) {
return nullptr;
}
textFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);
textFormat->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_NEAR);
textFormat->SetWordWrapping(DWRITE_WORD_WRAPPING_NO_WRAP);
IDWriteTextFormat* result = textFormat.Get();
m_textFormats.emplace(key, std::move(textFormat));
return result;
}
D2D1_COLOR_F NativeRenderer::ToD2DColor(const ::XCEngine::UI::UIColor& color) {
return D2D1::ColorF(color.r, color.g, color.b, color.a);
}
std::wstring NativeRenderer::Utf8ToWide(std::string_view text) {
if (text.empty()) {
return {};
}
const int sizeNeeded = MultiByteToWideChar(
CP_UTF8,
0,
text.data(),
static_cast<int>(text.size()),
nullptr,
0);
if (sizeNeeded <= 0) {
return {};
}
std::wstring wideText(static_cast<size_t>(sizeNeeded), L'\0');
MultiByteToWideChar(
CP_UTF8,
0,
text.data(),
static_cast<int>(text.size()),
wideText.data(),
sizeNeeded);
return wideText;
}
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,65 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEngine/UI/DrawData.h>
#include <d2d1.h>
#include <dwrite.h>
#include <wincodec.h>
#include <windows.h>
#include <wrl/client.h>
#include <filesystem>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace XCEngine::Tests::CoreUI::Host {
class NativeRenderer {
public:
bool Initialize(HWND hwnd);
void Shutdown();
void Resize(UINT width, UINT height);
bool Render(const ::XCEngine::UI::UIDrawData& drawData);
bool CaptureToPng(
const ::XCEngine::UI::UIDrawData& drawData,
UINT width,
UINT height,
const std::filesystem::path& outputPath,
std::string& outError);
private:
bool EnsureRenderTarget();
bool EnsureWicFactory(std::string& outError);
void DiscardRenderTarget();
bool CreateDeviceResources();
bool RenderToTarget(
ID2D1RenderTarget& renderTarget,
ID2D1SolidColorBrush& solidBrush,
const ::XCEngine::UI::UIDrawData& drawData);
void RenderCommand(
ID2D1RenderTarget& renderTarget,
ID2D1SolidColorBrush& solidBrush,
const ::XCEngine::UI::UIDrawCommand& command,
std::vector<D2D1_RECT_F>& clipStack);
IDWriteTextFormat* GetTextFormat(float fontSize);
static D2D1_COLOR_F ToD2DColor(const ::XCEngine::UI::UIColor& color);
static std::wstring Utf8ToWide(std::string_view text);
HWND m_hwnd = nullptr;
Microsoft::WRL::ComPtr<ID2D1Factory> m_d2dFactory;
Microsoft::WRL::ComPtr<IDWriteFactory> m_dwriteFactory;
Microsoft::WRL::ComPtr<IWICImagingFactory> m_wicFactory;
Microsoft::WRL::ComPtr<ID2D1HwndRenderTarget> m_renderTarget;
Microsoft::WRL::ComPtr<ID2D1SolidColorBrush> m_solidBrush;
std::unordered_map<int, Microsoft::WRL::ComPtr<IDWriteTextFormat>> m_textFormats;
bool m_wicComInitialized = false;
};
} // namespace XCEngine::Tests::CoreUI::Host

View File

@@ -0,0 +1,65 @@
<Theme name="CoreValidationTheme">
<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.style.warm" value="#4A3426" />
<Color name="color.style.cool" value="#243C4C" />
<Color name="color.style.borderWarm" value="#D0925B" />
<Color name="color.style.borderCool" value="#6BA3C7" />
<Color name="color.style.buttonAccent" value="#4B5E7B" />
<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" />
<Spacing name="space.loose" value="18" />
<Radius name="radius.panel" value="10" />
<Radius name="radius.control" value="8" />
<Radius name="radius.loose" value="16" />
</Tokens>
<Widgets>
<Widget type="View" style="CoreWorkspace">
<Property name="background" value="color.bg.workspace" />
<Property name="padding" value="space.shell" />
</Widget>
<Widget type="Card" style="CorePanel">
<Property name="background" value="color.bg.panel" />
<Property name="radius" value="radius.panel" />
<Property name="padding" value="space.panel" />
</Widget>
<Widget type="Card" style="CoreStyleWarmCard">
<Property name="background" value="color.style.warm" />
<Property name="borderColor" value="color.style.borderWarm" />
<Property name="borderWidth" value="1" />
<Property name="padding" value="space.loose" />
<Property name="radius" value="radius.loose" />
</Widget>
<Widget type="Card" style="CoreStyleCoolCard">
<Property name="background" value="color.style.cool" />
<Property name="borderColor" value="color.style.borderCool" />
<Property name="borderWidth" value="1" />
<Property name="padding" value="space.loose" />
<Property name="radius" value="radius.loose" />
</Widget>
<Widget type="Button" style="CoreChip">
<Property name="background" value="color.bg.selection" />
<Property name="radius" value="radius.control" />
</Widget>
<Widget type="Button" style="CoreStyleAccentButton">
<Property name="background" value="color.style.buttonAccent" />
<Property name="radius" value="radius.control" />
</Widget>
<Widget type="Text" style="CoreTextMuted">
<Property name="foreground" value="color.text.muted" />
<Property name="fontSize" value="14" />
</Widget>
</Widgets>
</Theme>

View File

@@ -0,0 +1,6 @@
add_subdirectory(theme_tokens)
add_custom_target(core_ui_style_integration_tests
DEPENDS
core_ui_style_theme_tokens_validation
)

View File

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

View File

@@ -0,0 +1,50 @@
<View
name="CoreStyleThemeTokens"
theme="../../shared/themes/core_validation.xctheme"
style="CoreWorkspace">
<Column padding="24" gap="16">
<Card
title="测试内容Core Style 主题 Token 与样式优先级"
subtitle="只验证 shared theme / named style / inline override不验证上层业务面板"
tone="accent"
height="108">
<Column gap="8">
<Text text="1. 检查下面三张卡片的背景、边框、圆角是否明显不同。" />
<Text text="2. Named Style Warm / Cool 应直接体现主题 TokenInline Override 应覆盖同名样式的背景色。" />
<Text text="3. 下方 Accent Button 应明显区别于默认按钮,用来确认 widget named style 已生效。" />
</Column>
</Card>
<Column gap="12">
<Card id="style-warm-card" style="CoreStyleWarmCard" title="Named Style Warm" subtitle="theme named style" height="112">
<Column gap="6">
<Text text="预期:暖色背景 + 暖色边框 + 较大的 padding。" />
</Column>
</Card>
<Card id="style-cool-card" style="CoreStyleCoolCard" title="Named Style Cool" subtitle="theme named style" height="112">
<Column gap="6">
<Text text="预期:冷色背景 + 冷色边框;应与 Warm 卡片明显区分。" />
</Column>
</Card>
<Card
id="style-inline-card"
style="CoreStyleWarmCard"
background="#6B4A2D"
borderColor="#D8A56B"
title="Inline Override"
subtitle="inline background overrides named style"
height="112">
<Column gap="6">
<Text text="预期:仍保留 Warm 卡片结构,但背景和边框应以 inline 值为准。" />
</Column>
</Card>
</Column>
<Row gap="12">
<Button id="style-default-button" text="Default Button" />
<Button id="style-accent-button" style="CoreStyleAccentButton" text="Accent Button" />
</Row>
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.style.theme_tokens");
}

View File

@@ -0,0 +1,6 @@
add_subdirectory(utf8_focus_surface)
add_custom_target(core_ui_text_integration_tests
DEPENDS
core_ui_text_utf8_focus_surface_validation
)

View File

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

View File

@@ -0,0 +1,43 @@
<View
name="CoreTextUtf8FocusSurface"
theme="../../shared/themes/core_validation.xctheme"
style="CoreWorkspace">
<Column padding="24" gap="16">
<Card
title="测试内容Core Text UTF-8 渲染与 textInput 焦点面"
subtitle="只验证共享文本渲染、字号差异和 textInput focus 标记;不伪造完整输入框"
tone="accent"
height="108">
<Column gap="8">
<Text text="1. 检查下面的 UTF-8 文本是否正常显示,不应出现乱码或错位。" />
<Text text="2. 按 Tab 把 focus 切到中间的 Text Input Surface右下角 Text input 应变为 active。" />
<Text text="3. 再按 Tab / Shift+Tab 离开时Text input 应恢复为 idle。" />
</Column>
</Card>
<Card title="UTF-8 Rendering" subtitle="shared text drawing only" height="144">
<Column gap="8">
<Text text="English / 中文 / UTF-8 / Delete / Backspace" />
<Text fontSize="20" text="大字号文本:删除键 Delete 与 Backspace 现在应分离。" />
<Text style="CoreTextMuted" text="多语言混排检查:层级、布局、主题、文本。" />
</Column>
</Card>
<Button id="text-focus-before" text="Focus Before" />
<Card
id="text-input-surface"
title="Text Input Surface"
subtitle="focusable + textInput marker"
focusable="true"
textInput="true"
height="132">
<Column gap="8">
<Text text="这个面板本身不是完整输入框,只用于验证 textInput focus 标记链路。" />
<Text text="当 focus 停在这里时,右下角 Text input 应显示 active。" />
</Column>
</Card>
<Button id="text-focus-after" text="Focus After" />
</Column>
</View>

View File

@@ -0,0 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
"core.text.utf8_focus_surface");
}

View File

@@ -1,21 +1,22 @@
set(CORE_UI_TEST_SOURCES set(CORE_UI_TEST_SOURCES
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_core_validation_registry.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_input_modifier_tracker.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_layout_engine.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_core.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_expansion_model.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_flat_hierarchy_helpers.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_input_dispatcher.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_keyboard_navigation_model.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_property_edit_model.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_selection_model.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_style_system.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_shortcut_scope.cpp ${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_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_interaction.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_interaction.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_model.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_model.cpp
# Migration bridge: legacy XCUI unit coverage still lives under tests/Core/UI ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_text_editing.cpp
# until it is moved into tests/UI/Core/unit without changing behavior. ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_text_input_controller.cpp
${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}) add_executable(core_ui_tests ${CORE_UI_TEST_SOURCES})
@@ -28,6 +29,7 @@ endif()
target_link_libraries(core_ui_tests target_link_libraries(core_ui_tests
PRIVATE PRIVATE
core_ui_validation_registry
XCEngine XCEngine
GTest::gtest GTest::gtest
GTest::gtest_main GTest::gtest_main
@@ -35,6 +37,7 @@ target_link_libraries(core_ui_tests
target_include_directories(core_ui_tests PRIVATE target_include_directories(core_ui_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include ${CMAKE_SOURCE_DIR}/engine/include
${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/tests/Fixtures ${CMAKE_SOURCE_DIR}/tests/Fixtures
) )

View File

@@ -0,0 +1,78 @@
#include <gtest/gtest.h>
#include "CoreValidationScenario.h"
#include <filesystem>
namespace {
using XCEngine::Tests::CoreUI::FindCoreValidationScenario;
using XCEngine::Tests::CoreUI::GetDefaultCoreValidationScenario;
using XCEngine::Tests::CoreUI::UIValidationDomain;
} // namespace
TEST(CoreValidationRegistryTest, KnownCoreValidationScenariosResolveToExistingResources) {
const auto* pointerScenario = FindCoreValidationScenario("core.input.pointer_states");
const auto* keyboardScenario = FindCoreValidationScenario("core.input.keyboard_focus");
const auto* scrollScenario = FindCoreValidationScenario("core.input.scroll_view");
const auto* shortcutScenario = FindCoreValidationScenario("core.input.shortcut_scope");
const auto* splitterScenario = FindCoreValidationScenario("core.layout.splitter_resize");
const auto* tabStripScenario = FindCoreValidationScenario("core.layout.tab_strip_selection");
const auto* workspaceScenario = FindCoreValidationScenario("core.layout.workspace_compose");
const auto* styleScenario = FindCoreValidationScenario("core.style.theme_tokens");
const auto* textScenario = FindCoreValidationScenario("core.text.utf8_focus_surface");
ASSERT_NE(pointerScenario, nullptr);
ASSERT_NE(keyboardScenario, nullptr);
ASSERT_NE(scrollScenario, nullptr);
ASSERT_NE(shortcutScenario, nullptr);
ASSERT_NE(splitterScenario, nullptr);
ASSERT_NE(tabStripScenario, nullptr);
ASSERT_NE(workspaceScenario, nullptr);
ASSERT_NE(styleScenario, nullptr);
ASSERT_NE(textScenario, nullptr);
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(scrollScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(tabStripScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(workspaceScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(styleScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(textScenario->domain, UIValidationDomain::Core);
EXPECT_EQ(pointerScenario->categoryId, "input");
EXPECT_EQ(keyboardScenario->categoryId, "input");
EXPECT_EQ(scrollScenario->categoryId, "input");
EXPECT_EQ(shortcutScenario->categoryId, "input");
EXPECT_EQ(splitterScenario->categoryId, "layout");
EXPECT_EQ(tabStripScenario->categoryId, "layout");
EXPECT_EQ(workspaceScenario->categoryId, "layout");
EXPECT_EQ(styleScenario->categoryId, "style");
EXPECT_EQ(textScenario->categoryId, "text");
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(scrollScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(scrollScenario->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));
EXPECT_TRUE(std::filesystem::exists(tabStripScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(tabStripScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(workspaceScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(workspaceScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(styleScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(styleScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(textScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(textScenario->themePath));
}
TEST(CoreValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) {
const auto& scenario = GetDefaultCoreValidationScenario();
EXPECT_EQ(scenario.id, "core.input.keyboard_focus");
EXPECT_EQ(scenario.domain, UIValidationDomain::Core);
EXPECT_TRUE(std::filesystem::exists(scenario.documentPath));
}

View File

@@ -0,0 +1,90 @@
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <gtest/gtest.h>
#include "InputModifierTracker.h"
#include <XCEngine/UI/Types.h>
#include <windows.h>
namespace {
using XCEngine::Tests::CoreUI::Host::InputModifierTracker;
using XCEngine::UI::UIInputEventType;
TEST(CoreInputModifierTrackerTest, 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(CoreInputModifierTrackerTest, 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(CoreInputModifierTrackerTest, 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,130 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Layout/LayoutEngine.h>
namespace {
using XCEngine::UI::UIRect;
using XCEngine::UI::UISize;
using XCEngine::UI::Layout::ArrangeOverlayLayout;
using XCEngine::UI::Layout::ArrangeStackLayout;
using XCEngine::UI::Layout::MeasureOverlayLayout;
using XCEngine::UI::Layout::MeasureStackLayout;
using XCEngine::UI::Layout::UILayoutAlignment;
using XCEngine::UI::Layout::UILayoutAxis;
using XCEngine::UI::Layout::UILayoutConstraints;
using XCEngine::UI::Layout::UILayoutItem;
using XCEngine::UI::Layout::UILayoutLength;
using XCEngine::UI::Layout::UILayoutThickness;
using XCEngine::UI::Layout::UIOverlayLayoutOptions;
using XCEngine::UI::Layout::UIStackLayoutOptions;
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(UI_Layout, MeasureHorizontalStackAccumulatesSpacingPaddingAndCrossExtent) {
UIStackLayoutOptions options = {};
options.axis = UILayoutAxis::Horizontal;
options.spacing = 5.0f;
options.padding = UILayoutThickness::Symmetric(10.0f, 6.0f);
std::vector<UILayoutItem> items(2);
items[0].desiredContentSize = UISize(40.0f, 20.0f);
items[1].desiredContentSize = UISize(60.0f, 30.0f);
const auto result = MeasureStackLayout(options, items);
EXPECT_FLOAT_EQ(result.desiredSize.width, 125.0f);
EXPECT_FLOAT_EQ(result.desiredSize.height, 42.0f);
}
TEST(UI_Layout, ArrangeHorizontalStackDistributesRemainingSpaceToStretchChildren) {
UIStackLayoutOptions options = {};
options.axis = UILayoutAxis::Horizontal;
options.spacing = 5.0f;
options.padding = UILayoutThickness::Uniform(10.0f);
std::vector<UILayoutItem> items(3);
items[0].width = UILayoutLength::Pixels(100.0f);
items[0].desiredContentSize = UISize(10.0f, 20.0f);
items[1].width = UILayoutLength::Stretch(1.0f);
items[1].desiredContentSize = UISize(30.0f, 20.0f);
items[2].width = UILayoutLength::Pixels(50.0f);
items[2].desiredContentSize = UISize(10.0f, 20.0f);
const auto result = ArrangeStackLayout(options, items, UIRect(0.0f, 0.0f, 300.0f, 80.0f));
ExpectRect(result.children[0].arrangedRect, 10.0f, 10.0f, 100.0f, 20.0f);
ExpectRect(result.children[1].arrangedRect, 115.0f, 10.0f, 120.0f, 20.0f);
ExpectRect(result.children[2].arrangedRect, 240.0f, 10.0f, 50.0f, 20.0f);
}
TEST(UI_Layout, ArrangeVerticalStackSupportsCrossAxisStretch) {
UIStackLayoutOptions options = {};
options.axis = UILayoutAxis::Vertical;
options.spacing = 4.0f;
options.padding = UILayoutThickness::Symmetric(8.0f, 6.0f);
std::vector<UILayoutItem> items(2);
items[0].desiredContentSize = UISize(40.0f, 10.0f);
items[0].horizontalAlignment = UILayoutAlignment::Stretch;
items[1].desiredContentSize = UISize(60.0f, 20.0f);
const auto result = ArrangeStackLayout(options, items, UIRect(0.0f, 0.0f, 200.0f, 100.0f));
ExpectRect(result.children[0].arrangedRect, 8.0f, 6.0f, 184.0f, 10.0f);
ExpectRect(result.children[1].arrangedRect, 8.0f, 20.0f, 60.0f, 20.0f);
}
TEST(UI_Layout, ArrangeOverlaySupportsCenterAndStretch) {
UIOverlayLayoutOptions options = {};
options.padding = UILayoutThickness::Uniform(10.0f);
std::vector<UILayoutItem> items(2);
items[0].desiredContentSize = UISize(40.0f, 20.0f);
items[0].horizontalAlignment = UILayoutAlignment::Center;
items[0].verticalAlignment = UILayoutAlignment::Center;
items[1].desiredContentSize = UISize(10.0f, 10.0f);
items[1].width = UILayoutLength::Stretch();
items[1].height = UILayoutLength::Stretch();
items[1].margin = UILayoutThickness::Uniform(5.0f);
const auto result = ArrangeOverlayLayout(options, items, UIRect(0.0f, 0.0f, 100.0f, 60.0f));
ExpectRect(result.children[0].arrangedRect, 30.0f, 20.0f, 40.0f, 20.0f);
ExpectRect(result.children[1].arrangedRect, 15.0f, 15.0f, 70.0f, 30.0f);
}
TEST(UI_Layout, MeasureOverlayRespectsItemMinMaxAndAvailableConstraints) {
UIOverlayLayoutOptions options = {};
std::vector<UILayoutItem> items(1);
items[0].width = UILayoutLength::Pixels(500.0f);
items[0].desiredContentSize = UISize(10.0f, 10.0f);
items[0].minSize = UISize(0.0f, 50.0f);
items[0].maxSize = UISize(200.0f, 120.0f);
const auto result = MeasureOverlayLayout(
options,
items,
UILayoutConstraints::Bounded(150.0f, 100.0f));
EXPECT_FLOAT_EQ(result.children[0].measuredSize.width, 150.0f);
EXPECT_FLOAT_EQ(result.children[0].measuredSize.height, 50.0f);
EXPECT_FLOAT_EQ(result.desiredSize.width, 150.0f);
EXPECT_FLOAT_EQ(result.desiredSize.height, 50.0f);
}

View File

@@ -0,0 +1,240 @@
#include <gtest/gtest.h>
#include <XCEngine/Core/Asset/IResource.h>
#include <XCEngine/Resources/UI/UIDocuments.h>
#include <XCEngine/UI/Core/UIContext.h>
namespace {
using XCEngine::UI::HasAnyDirtyFlags;
using XCEngine::UI::IUIViewModel;
using XCEngine::UI::RevisionedViewModelBase;
using XCEngine::UI::UIBuildContext;
using XCEngine::UI::UIBuildElementDesc;
using XCEngine::UI::UIContext;
using XCEngine::UI::UIDirtyFlags;
using XCEngine::UI::UIElementChangeKind;
using XCEngine::UI::UIElementId;
using XCEngine::UI::UIElementNode;
using XCEngine::UI::UIElementTree;
using XCEngine::Resources::UIDocumentKind;
using XCEngine::Resources::UIDocumentModel;
using XCEngine::Resources::UISchema;
using XCEngine::Resources::UIView;
class TestViewModel : public RevisionedViewModelBase {
public:
void Touch() {
MarkViewModelChanged();
}
};
UIBuildElementDesc MakeElement(
UIElementId id,
const char* typeName,
std::uint64_t localStateRevision = 0,
const IUIViewModel* viewModel = nullptr,
std::uint64_t structuralRevision = 0) {
UIBuildElementDesc desc = {};
desc.id = id;
desc.typeName = typeName;
desc.localStateRevision = localStateRevision;
desc.viewModel = viewModel;
desc.structuralRevision = structuralRevision;
return desc;
}
void BuildBasicTree(UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(2, "Label")));
auto panel = buildContext.PushElement(MakeElement(3, "Panel"));
EXPECT_TRUE(static_cast<bool>(panel));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(4, "Button")));
}
const UIElementNode& RequireNode(const UIElementTree& tree, UIElementId id) {
const UIElementNode* node = tree.FindNode(id);
EXPECT_NE(node, nullptr);
return *node;
}
TEST(UICoreTest, RebuildCreatesStableParentChildTree) {
UIContext context = {};
const auto result = context.Rebuild(BuildBasicTree);
EXPECT_TRUE(result.succeeded);
EXPECT_TRUE(result.treeChanged);
EXPECT_EQ(result.generation, 1u);
EXPECT_EQ(context.GetElementTree().GetRootId(), 1u);
EXPECT_EQ(context.GetElementTree().GetNodeCount(), 4u);
EXPECT_TRUE(result.HasChange(1));
EXPECT_TRUE(result.HasChange(4));
const UIElementNode& root = RequireNode(context.GetElementTree(), 1);
ASSERT_EQ(root.childIds.size(), 2u);
EXPECT_EQ(root.childIds[0], 2u);
EXPECT_EQ(root.childIds[1], 3u);
EXPECT_EQ(root.depth, 0u);
const UIElementNode& panel = RequireNode(context.GetElementTree(), 3);
ASSERT_EQ(panel.childIds.size(), 1u);
EXPECT_EQ(panel.childIds[0], 4u);
EXPECT_EQ(panel.depth, 1u);
ASSERT_EQ(result.dirtyRootIds.size(), 1u);
EXPECT_EQ(result.dirtyRootIds[0], 1u);
}
TEST(UICoreTest, RebuildSkipsUnchangedTreeAfterDirtyFlagsAreCleared) {
UIContext context = {};
const auto initial = context.Rebuild(BuildBasicTree);
ASSERT_TRUE(initial.succeeded);
context.GetElementTree().ClearAllDirtyFlags();
const auto result = context.Rebuild(BuildBasicTree);
EXPECT_TRUE(result.succeeded);
EXPECT_FALSE(result.treeChanged);
EXPECT_TRUE(result.changes.empty());
EXPECT_TRUE(result.dirtyRootIds.empty());
}
TEST(UICoreTest, LocalStateChangeOnlyInvalidatesTheChangedLeaf) {
UIContext context = {};
ASSERT_TRUE(context.Rebuild(BuildBasicTree).succeeded);
context.GetElementTree().ClearAllDirtyFlags();
const auto result = context.Rebuild([](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(2, "Label", 1)));
auto panel = buildContext.PushElement(MakeElement(3, "Panel"));
EXPECT_TRUE(static_cast<bool>(panel));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(4, "Button")));
});
EXPECT_TRUE(result.succeeded);
EXPECT_TRUE(result.treeChanged);
ASSERT_EQ(result.changes.size(), 1u);
ASSERT_NE(result.FindChange(2), nullptr);
EXPECT_EQ(result.FindChange(2)->kind, UIElementChangeKind::Updated);
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::LocalState));
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::Paint));
ASSERT_EQ(result.dirtyRootIds.size(), 1u);
EXPECT_EQ(result.dirtyRootIds[0], 2u);
const UIElementNode& leaf = RequireNode(context.GetElementTree(), 2);
EXPECT_TRUE(HasAnyDirtyFlags(leaf.dirtyFlags, UIDirtyFlags::LocalState));
EXPECT_FALSE(RequireNode(context.GetElementTree(), 1).IsDirty());
}
TEST(UICoreTest, ViewModelRevisionChangeInvalidatesBoundElement) {
TestViewModel viewModel = {};
UIContext context = {};
ASSERT_TRUE(context.Rebuild([&](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(2, "Inspector", 0, &viewModel)));
}).succeeded);
context.GetElementTree().ClearAllDirtyFlags();
viewModel.Touch();
const auto result = context.Rebuild([&](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(2, "Inspector", 0, &viewModel)));
});
EXPECT_TRUE(result.succeeded);
ASSERT_NE(result.FindChange(2), nullptr);
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::ViewModel));
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::Paint));
ASSERT_EQ(result.dirtyRootIds.size(), 1u);
EXPECT_EQ(result.dirtyRootIds[0], 2u);
}
TEST(UICoreTest, StructuralChangesBubbleLayoutInvalidationToAncestors) {
UIContext context = {};
ASSERT_TRUE(context.Rebuild([](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
auto panel = buildContext.PushElement(MakeElement(2, "Panel"));
EXPECT_TRUE(static_cast<bool>(panel));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(3, "Text")));
}).succeeded);
context.GetElementTree().ClearAllDirtyFlags();
const auto result = context.Rebuild([](UIBuildContext& buildContext) {
auto root = buildContext.PushElement(MakeElement(1, "Root"));
EXPECT_TRUE(static_cast<bool>(root));
auto panel = buildContext.PushElement(MakeElement(2, "Panel"));
EXPECT_TRUE(static_cast<bool>(panel));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(3, "Text")));
EXPECT_TRUE(buildContext.AddLeaf(MakeElement(4, "Icon")));
});
EXPECT_TRUE(result.succeeded);
EXPECT_TRUE(result.HasChange(4));
ASSERT_NE(result.FindChange(2), nullptr);
EXPECT_TRUE(HasAnyDirtyFlags(result.FindChange(2)->dirtyFlags, UIDirtyFlags::Structure));
const UIElementNode& root = RequireNode(context.GetElementTree(), 1);
const UIElementNode& panel = RequireNode(context.GetElementTree(), 2);
EXPECT_TRUE(HasAnyDirtyFlags(root.dirtyFlags, UIDirtyFlags::Layout));
EXPECT_TRUE(HasAnyDirtyFlags(panel.dirtyFlags, UIDirtyFlags::Structure));
ASSERT_EQ(result.dirtyRootIds.size(), 1u);
EXPECT_EQ(result.dirtyRootIds[0], 1u);
}
TEST(UICoreTest, RebuildFailsWhenElementScopesRemainOpen) {
UIContext context = {};
const auto result = context.Rebuild([](UIBuildContext& buildContext) {
EXPECT_TRUE(buildContext.BeginElement(MakeElement(1, "Root")));
});
EXPECT_FALSE(result.succeeded);
EXPECT_FALSE(result.errorMessage.empty());
EXPECT_EQ(context.GetElementTree().GetNodeCount(), 0u);
}
TEST(UICoreTest, UIDocumentResourcesAcceptMovedDocumentModels) {
UIDocumentModel viewDocument = {};
viewDocument.kind = UIDocumentKind::View;
viewDocument.sourcePath = "Assets/UI/Test.xcui";
viewDocument.displayName = "TestView";
viewDocument.rootNode.tagName = "View";
viewDocument.valid = true;
UIView view = {};
XCEngine::Resources::IResource::ConstructParams params = {};
params.name = "TestView";
params.path = viewDocument.sourcePath;
params.guid = XCEngine::Resources::ResourceGUID::Generate(params.path);
view.Initialize(params);
view.SetDocumentModel(std::move(viewDocument));
EXPECT_EQ(view.GetRootNode().tagName, "View");
EXPECT_EQ(view.GetSourcePath(), "Assets/UI/Test.xcui");
UIDocumentModel schemaDocument = {};
schemaDocument.kind = UIDocumentKind::Schema;
schemaDocument.sourcePath = "Assets/UI/Test.xcschema";
schemaDocument.displayName = "TestSchema";
schemaDocument.rootNode.tagName = "Schema";
schemaDocument.schemaDefinition.name = "TestSchema";
schemaDocument.schemaDefinition.valid = true;
schemaDocument.valid = true;
UISchema schema = {};
params.name = "TestSchema";
params.path = schemaDocument.sourcePath;
params.guid = XCEngine::Resources::ResourceGUID::Generate(params.path);
schema.Initialize(params);
schema.SetDocumentModel(std::move(schemaDocument));
EXPECT_TRUE(schema.GetSchemaDefinition().valid);
EXPECT_EQ(schema.GetSchemaDefinition().name, "TestSchema");
}
} // namespace

View File

@@ -0,0 +1,45 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
namespace {
using XCEngine::UI::Widgets::UIExpansionModel;
TEST(UIExpansionModelTest, ExpandCollapseAndClearTrackExpandedItems) {
UIExpansionModel expansion = {};
EXPECT_FALSE(expansion.HasExpandedItems());
EXPECT_EQ(expansion.GetExpandedCount(), 0u);
EXPECT_TRUE(expansion.Expand("treeAssetsRoot"));
EXPECT_TRUE(expansion.IsExpanded("treeAssetsRoot"));
EXPECT_TRUE(expansion.HasExpandedItems());
EXPECT_EQ(expansion.GetExpandedCount(), 1u);
EXPECT_FALSE(expansion.Expand("treeAssetsRoot"));
EXPECT_TRUE(expansion.Collapse("treeAssetsRoot"));
EXPECT_FALSE(expansion.IsExpanded("treeAssetsRoot"));
EXPECT_EQ(expansion.GetExpandedCount(), 0u);
EXPECT_FALSE(expansion.Collapse("treeAssetsRoot"));
EXPECT_FALSE(expansion.Clear());
}
TEST(UIExpansionModelTest, SetAndToggleExpandedReplaceStateForMatchingItem) {
UIExpansionModel expansion = {};
EXPECT_TRUE(expansion.SetExpanded("inspectorTransform", true));
EXPECT_TRUE(expansion.IsExpanded("inspectorTransform"));
EXPECT_EQ(expansion.GetExpandedCount(), 1u);
EXPECT_FALSE(expansion.SetExpanded("inspectorTransform", true));
EXPECT_TRUE(expansion.ToggleExpanded("inspectorTransform"));
EXPECT_FALSE(expansion.IsExpanded("inspectorTransform"));
EXPECT_TRUE(expansion.ToggleExpanded("inspectorMesh"));
EXPECT_TRUE(expansion.IsExpanded("inspectorMesh"));
EXPECT_TRUE(expansion.SetExpanded("inspectorMesh", false));
EXPECT_FALSE(expansion.IsExpanded("inspectorMesh"));
}
} // namespace

View File

@@ -0,0 +1,145 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIFlatHierarchyHelpers.h>
#include <array>
#include <unordered_set>
#include <vector>
namespace {
using XCEngine::UI::Widgets::kInvalidUIFlatHierarchyItemOffset;
using XCEngine::UI::Widgets::UIFlatHierarchyFindFirstVisibleChildOffset;
using XCEngine::UI::Widgets::UIFlatHierarchyFindParentOffset;
using XCEngine::UI::Widgets::UIFlatHierarchyHasChildren;
using XCEngine::UI::Widgets::UIFlatHierarchyIsVisible;
struct FlatHierarchyItem {
float depth = 0.0f;
};
TEST(UIFlatHierarchyHelpersTest, HasChildrenAndParentResolutionTrackIndentedBranches) {
const std::array<FlatHierarchyItem, 5> items = {{
{ 0.0f },
{ 1.0f },
{ 2.0f },
{ 1.0f },
{ 0.0f },
}};
const std::vector<std::size_t> itemIndices = { 0u, 1u, 2u, 3u, 4u };
const auto resolveDepth = [&items](std::size_t itemIndex) {
return items[itemIndex].depth;
};
EXPECT_TRUE(UIFlatHierarchyHasChildren(itemIndices, 0u, resolveDepth));
EXPECT_TRUE(UIFlatHierarchyHasChildren(itemIndices, 1u, resolveDepth));
EXPECT_FALSE(UIFlatHierarchyHasChildren(itemIndices, 2u, resolveDepth));
EXPECT_FALSE(UIFlatHierarchyHasChildren(itemIndices, 3u, resolveDepth));
EXPECT_FALSE(UIFlatHierarchyHasChildren(itemIndices, 4u, resolveDepth));
EXPECT_EQ(
UIFlatHierarchyFindParentOffset(itemIndices, 0u, resolveDepth),
kInvalidUIFlatHierarchyItemOffset);
EXPECT_EQ(UIFlatHierarchyFindParentOffset(itemIndices, 1u, resolveDepth), 0u);
EXPECT_EQ(UIFlatHierarchyFindParentOffset(itemIndices, 2u, resolveDepth), 1u);
EXPECT_EQ(UIFlatHierarchyFindParentOffset(itemIndices, 3u, resolveDepth), 0u);
EXPECT_EQ(
UIFlatHierarchyFindParentOffset(itemIndices, 99u, resolveDepth),
kInvalidUIFlatHierarchyItemOffset);
}
TEST(UIFlatHierarchyHelpersTest, VisibilityFollowsCollapsedAncestorStateAcrossDepthTransitions) {
const std::array<FlatHierarchyItem, 4> items = {{
{ 0.0f },
{ 1.0f },
{ 2.0f },
{ 1.0f },
}};
const std::vector<std::size_t> itemIndices = { 0u, 1u, 2u, 3u };
const auto resolveDepth = [&items](std::size_t itemIndex) {
return items[itemIndex].depth;
};
std::unordered_set<std::size_t> expandedItems = { 0u };
const auto isExpanded = [&expandedItems](std::size_t itemIndex) {
return expandedItems.contains(itemIndex);
};
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 0u, resolveDepth, isExpanded));
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 1u, resolveDepth, isExpanded));
EXPECT_FALSE(UIFlatHierarchyIsVisible(itemIndices, 2u, resolveDepth, isExpanded));
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 3u, resolveDepth, isExpanded));
expandedItems.insert(1u);
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 2u, resolveDepth, isExpanded));
expandedItems.clear();
EXPECT_FALSE(UIFlatHierarchyIsVisible(itemIndices, 1u, resolveDepth, isExpanded));
EXPECT_FALSE(UIFlatHierarchyIsVisible(itemIndices, 2u, resolveDepth, isExpanded));
}
TEST(UIFlatHierarchyHelpersTest, FirstVisibleChildSkipsCollapsedDescendantsUntilExpanded) {
const std::array<FlatHierarchyItem, 4> items = {{
{ 0.0f },
{ 1.0f },
{ 2.0f },
{ 1.0f },
}};
const std::vector<std::size_t> itemIndices = { 0u, 1u, 2u, 3u };
const auto resolveDepth = [&items](std::size_t itemIndex) {
return items[itemIndex].depth;
};
std::unordered_set<std::size_t> expandedItems = { 0u };
const auto isExpanded = [&expandedItems](std::size_t itemIndex) {
return expandedItems.contains(itemIndex);
};
const auto isVisible = [&itemIndices, &resolveDepth, &isExpanded](std::size_t itemIndex) {
for (std::size_t itemOffset = 0; itemOffset < itemIndices.size(); ++itemOffset) {
if (itemIndices[itemOffset] == itemIndex) {
return UIFlatHierarchyIsVisible(itemIndices, itemOffset, resolveDepth, isExpanded);
}
}
return false;
};
EXPECT_EQ(
UIFlatHierarchyFindFirstVisibleChildOffset(itemIndices, 0u, resolveDepth, isVisible),
1u);
EXPECT_EQ(
UIFlatHierarchyFindFirstVisibleChildOffset(itemIndices, 1u, resolveDepth, isVisible),
kInvalidUIFlatHierarchyItemOffset);
expandedItems.insert(1u);
EXPECT_EQ(
UIFlatHierarchyFindFirstVisibleChildOffset(itemIndices, 1u, resolveDepth, isVisible),
2u);
}
TEST(UIFlatHierarchyHelpersTest, NegativeDepthsClampToRootsForHierarchyQueries) {
const std::array<FlatHierarchyItem, 3> items = {{
{ -3.0f },
{ 1.0f },
{ -1.0f },
}};
const std::vector<std::size_t> itemIndices = { 0u, 1u, 2u };
const auto resolveDepth = [&items](std::size_t itemIndex) {
return items[itemIndex].depth;
};
const auto isExpanded = [](std::size_t itemIndex) {
return itemIndex == 0u;
};
EXPECT_TRUE(UIFlatHierarchyHasChildren(itemIndices, 0u, resolveDepth));
EXPECT_EQ(UIFlatHierarchyFindParentOffset(itemIndices, 1u, resolveDepth), 0u);
EXPECT_EQ(
UIFlatHierarchyFindParentOffset(itemIndices, 2u, resolveDepth),
kInvalidUIFlatHierarchyItemOffset);
EXPECT_TRUE(UIFlatHierarchyIsVisible(itemIndices, 2u, resolveDepth, isExpanded));
}
} // namespace

View File

@@ -0,0 +1,146 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Input/UIInputDispatcher.h>
#include <algorithm>
#include <vector>
namespace {
using XCEngine::UI::UIInputDispatchRequest;
using XCEngine::UI::UIInputDispatcher;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIInputPath;
using XCEngine::UI::UIPointerButton;
using XCEngine::Input::KeyCode;
UIInputEvent MakePointerEvent(
UIInputEventType type,
UIPointerButton button = UIPointerButton::None) {
UIInputEvent event = {};
event.type = type;
event.pointerButton = button;
return event;
}
} // namespace
TEST(UIInputDispatcherTest, PointerDownTransfersFocusAndStartsActivePath) {
UIInputDispatcher dispatcher{};
const UIInputPath hoveredPath = { 10u, 20u, 30u };
std::vector<UIInputDispatchRequest> routedRequests = {};
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerButtonDown, UIPointerButton::Left),
hoveredPath,
[&](const UIInputDispatchRequest& request) {
routedRequests.push_back(request);
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_TRUE(summary.focusChange.Changed());
EXPECT_EQ(summary.focusChange.previousPath, UIInputPath());
EXPECT_EQ(summary.focusChange.currentPath, hoveredPath);
EXPECT_EQ(dispatcher.GetFocusController().GetFocusedPath(), hoveredPath);
EXPECT_EQ(dispatcher.GetFocusController().GetActivePath(), hoveredPath);
ASSERT_FALSE(routedRequests.empty());
EXPECT_EQ(summary.routing.plan.targetPath, hoveredPath);
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Hovered);
const auto targetIt = std::find_if(
routedRequests.begin(),
routedRequests.end(),
[](const UIInputDispatchRequest& request) {
return request.isTargetElement;
});
ASSERT_NE(targetIt, routedRequests.end());
EXPECT_EQ(targetIt->elementId, hoveredPath.Target());
}
TEST(UIInputDispatcherTest, PointerCaptureOverridesHoveredRouteForPointerEvents) {
UIInputDispatcher dispatcher{};
const UIInputPath hoveredPath = { 41u, 42u };
const UIInputPath capturePath = { 7u, 8u, 9u };
dispatcher.GetFocusController().SetPointerCapturePath(capturePath);
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerMove),
hoveredPath,
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Captured);
EXPECT_EQ(summary.routing.plan.targetPath, capturePath);
}
TEST(UIInputDispatcherTest, PointerButtonUpClearsActivePathAfterDispatch) {
UIInputDispatcher dispatcher{};
const UIInputPath activePath = { 2u, 4u, 6u };
dispatcher.GetFocusController().SetActivePath(activePath);
const auto summary = dispatcher.Dispatch(
MakePointerEvent(UIInputEventType::PointerButtonUp, UIPointerButton::Left),
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_FALSE(summary.routing.handled);
EXPECT_FALSE(dispatcher.GetFocusController().HasActivePath());
}
TEST(UIInputDispatcherTest, KeyboardEventsRouteToFocusedPath) {
UIInputDispatcher dispatcher{};
const UIInputPath focusedPath = { 101u, 202u };
dispatcher.GetFocusController().SetFocusedPath(focusedPath);
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = 'F';
const auto summary = dispatcher.Dispatch(
event,
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_EQ(summary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Focused);
EXPECT_EQ(summary.routing.plan.targetPath, focusedPath);
}
TEST(UIInputDispatcherTest, KeyboardActivationStartsAndClearsActivePathOnFocusedTarget) {
UIInputDispatcher dispatcher{};
const UIInputPath focusedPath = { 301u, 302u, 303u };
dispatcher.GetFocusController().SetFocusedPath(focusedPath);
UIInputEvent keyDown = {};
keyDown.type = UIInputEventType::KeyDown;
keyDown.keyCode = static_cast<std::int32_t>(KeyCode::Enter);
const auto downSummary = dispatcher.Dispatch(
keyDown,
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_EQ(downSummary.routing.plan.targetKind, XCEngine::UI::UIInputTargetKind::Focused);
EXPECT_EQ(downSummary.routing.plan.targetPath, focusedPath);
EXPECT_EQ(dispatcher.GetFocusController().GetActivePath(), focusedPath);
UIInputEvent keyUp = {};
keyUp.type = UIInputEventType::KeyUp;
keyUp.keyCode = static_cast<std::int32_t>(KeyCode::Enter);
dispatcher.Dispatch(
keyUp,
{},
[](const UIInputDispatchRequest&) {
return XCEngine::UI::UIInputDispatchDecision{};
});
EXPECT_FALSE(dispatcher.GetFocusController().HasActivePath());
}

View File

@@ -0,0 +1,101 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIKeyboardNavigationModel.h>
namespace {
using XCEngine::UI::Widgets::UIKeyboardNavigationModel;
TEST(UIKeyboardNavigationModelTest, EmptyModelStartsWithoutCurrentIndexOrAnchor) {
UIKeyboardNavigationModel navigation = {};
EXPECT_EQ(navigation.GetItemCount(), 0u);
EXPECT_FALSE(navigation.HasCurrentIndex());
EXPECT_EQ(navigation.GetCurrentIndex(), UIKeyboardNavigationModel::InvalidIndex);
EXPECT_FALSE(navigation.HasSelectionAnchor());
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), UIKeyboardNavigationModel::InvalidIndex);
EXPECT_FALSE(navigation.MoveNext());
EXPECT_FALSE(navigation.MovePrevious());
EXPECT_FALSE(navigation.MoveHome());
EXPECT_FALSE(navigation.MoveEnd());
}
TEST(UIKeyboardNavigationModelTest, SetCurrentIndexAndDirectionalMovesTrackCurrentIndexAndAnchor) {
UIKeyboardNavigationModel navigation = {};
ASSERT_TRUE(navigation.SetItemCount(4u));
EXPECT_TRUE(navigation.SetCurrentIndex(1u));
EXPECT_EQ(navigation.GetCurrentIndex(), 1u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u);
EXPECT_TRUE(navigation.MoveNext());
EXPECT_EQ(navigation.GetCurrentIndex(), 2u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 2u);
EXPECT_TRUE(navigation.MoveEnd());
EXPECT_EQ(navigation.GetCurrentIndex(), 3u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u);
EXPECT_FALSE(navigation.MoveNext());
EXPECT_TRUE(navigation.MoveHome());
EXPECT_EQ(navigation.GetCurrentIndex(), 0u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 0u);
EXPECT_FALSE(navigation.MovePrevious());
}
TEST(UIKeyboardNavigationModelTest, MovePreviousAndEndSeedNavigationWhenCurrentIndexIsUnset) {
UIKeyboardNavigationModel navigation = {};
ASSERT_TRUE(navigation.SetItemCount(5u));
EXPECT_TRUE(navigation.MovePrevious());
EXPECT_EQ(navigation.GetCurrentIndex(), 4u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 4u);
EXPECT_TRUE(navigation.ClearCurrentIndex());
EXPECT_TRUE(navigation.ClearSelectionAnchor());
EXPECT_FALSE(navigation.HasCurrentIndex());
EXPECT_FALSE(navigation.HasSelectionAnchor());
EXPECT_TRUE(navigation.MoveEnd());
EXPECT_EQ(navigation.GetCurrentIndex(), 4u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 4u);
}
TEST(UIKeyboardNavigationModelTest, ExplicitAnchorCanBePreservedUntilNavigationCollapsesIt) {
UIKeyboardNavigationModel navigation = {};
ASSERT_TRUE(navigation.SetItemCount(6u));
EXPECT_TRUE(navigation.SetSelectionAnchorIndex(1u));
EXPECT_TRUE(navigation.SetCurrentIndex(4u, false));
EXPECT_EQ(navigation.GetCurrentIndex(), 4u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u);
EXPECT_TRUE(navigation.MovePrevious());
EXPECT_EQ(navigation.GetCurrentIndex(), 3u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u);
}
TEST(UIKeyboardNavigationModelTest, ItemCountChangesClampCurrentIndexAndSelectionAnchor) {
UIKeyboardNavigationModel navigation = {};
ASSERT_TRUE(navigation.SetItemCount(5u));
ASSERT_TRUE(navigation.SetSelectionAnchorIndex(3u));
ASSERT_TRUE(navigation.SetCurrentIndex(4u, false));
EXPECT_TRUE(navigation.SetItemCount(4u));
EXPECT_EQ(navigation.GetCurrentIndex(), 3u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 3u);
EXPECT_FALSE(navigation.SetCurrentIndex(3u, false));
EXPECT_TRUE(navigation.SetSelectionAnchorIndex(2u));
EXPECT_TRUE(navigation.SetItemCount(2u));
EXPECT_EQ(navigation.GetCurrentIndex(), 1u);
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), 1u);
EXPECT_TRUE(navigation.SetItemCount(0u));
EXPECT_FALSE(navigation.HasCurrentIndex());
EXPECT_EQ(navigation.GetCurrentIndex(), UIKeyboardNavigationModel::InvalidIndex);
EXPECT_FALSE(navigation.HasSelectionAnchor());
EXPECT_EQ(navigation.GetSelectionAnchorIndex(), UIKeyboardNavigationModel::InvalidIndex);
}
} // namespace

View File

@@ -0,0 +1,80 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UIPropertyEditModel.h>
namespace {
using XCEngine::UI::Widgets::UIPropertyEditModel;
TEST(UIPropertyEditModelTest, BeginEditTracksActiveFieldAndInitialValue) {
UIPropertyEditModel model = {};
EXPECT_FALSE(model.HasActiveEdit());
EXPECT_TRUE(model.GetActiveFieldId().empty());
EXPECT_TRUE(model.GetStagedValue().empty());
EXPECT_FALSE(model.IsDirty());
EXPECT_FALSE(model.BeginEdit("", "12.0"));
EXPECT_TRUE(model.BeginEdit("transform.position.x", "12.0"));
EXPECT_TRUE(model.HasActiveEdit());
EXPECT_EQ(model.GetActiveFieldId(), "transform.position.x");
EXPECT_EQ(model.GetStagedValue(), "12.0");
EXPECT_FALSE(model.IsDirty());
EXPECT_FALSE(model.BeginEdit("transform.position.x", "12.0"));
}
TEST(UIPropertyEditModelTest, UpdateStagedValueTracksDirtyAgainstBaseline) {
UIPropertyEditModel model = {};
EXPECT_FALSE(model.UpdateStagedValue("3.5"));
ASSERT_TRUE(model.BeginEdit("light.intensity", "1.0"));
EXPECT_TRUE(model.UpdateStagedValue("3.5"));
EXPECT_EQ(model.GetStagedValue(), "3.5");
EXPECT_TRUE(model.IsDirty());
EXPECT_FALSE(model.UpdateStagedValue("3.5"));
EXPECT_TRUE(model.UpdateStagedValue("1.0"));
EXPECT_EQ(model.GetStagedValue(), "1.0");
EXPECT_FALSE(model.IsDirty());
}
TEST(UIPropertyEditModelTest, CommitEditReturnsPayloadAndClearsState) {
UIPropertyEditModel model = {};
ASSERT_TRUE(model.BeginEdit("material.albedo", "#ffffff"));
ASSERT_TRUE(model.UpdateStagedValue("#ffcc00"));
std::string committedFieldId = {};
std::string committedValue = {};
EXPECT_TRUE(model.CommitEdit(&committedFieldId, &committedValue));
EXPECT_EQ(committedFieldId, "material.albedo");
EXPECT_EQ(committedValue, "#ffcc00");
EXPECT_FALSE(model.HasActiveEdit());
EXPECT_TRUE(model.GetActiveFieldId().empty());
EXPECT_TRUE(model.GetStagedValue().empty());
EXPECT_FALSE(model.IsDirty());
EXPECT_FALSE(model.CommitEdit(&committedFieldId, &committedValue));
}
TEST(UIPropertyEditModelTest, CancelEditDropsStagedChangesAndResetsSession) {
UIPropertyEditModel model = {};
ASSERT_TRUE(model.BeginEdit("camera.fov", "60"));
ASSERT_TRUE(model.UpdateStagedValue("75"));
ASSERT_TRUE(model.IsDirty());
EXPECT_TRUE(model.CancelEdit());
EXPECT_FALSE(model.HasActiveEdit());
EXPECT_TRUE(model.GetActiveFieldId().empty());
EXPECT_TRUE(model.GetStagedValue().empty());
EXPECT_FALSE(model.IsDirty());
EXPECT_FALSE(model.CancelEdit());
EXPECT_TRUE(model.BeginEdit("camera.nearClip", "0.3"));
EXPECT_EQ(model.GetActiveFieldId(), "camera.nearClip");
EXPECT_EQ(model.GetStagedValue(), "0.3");
EXPECT_FALSE(model.IsDirty());
}
} // namespace

View File

@@ -0,0 +1,42 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h>
namespace {
using XCEngine::UI::Widgets::UISelectionModel;
TEST(UISelectionModelTest, SetAndClearSelectionTrackCurrentId) {
UISelectionModel selection = {};
EXPECT_FALSE(selection.HasSelection());
EXPECT_TRUE(selection.GetSelectedId().empty());
EXPECT_TRUE(selection.SetSelection("assetLighting"));
EXPECT_TRUE(selection.HasSelection());
EXPECT_TRUE(selection.IsSelected("assetLighting"));
EXPECT_EQ(selection.GetSelectedId(), "assetLighting");
EXPECT_FALSE(selection.SetSelection("assetLighting"));
EXPECT_TRUE(selection.ClearSelection());
EXPECT_FALSE(selection.HasSelection());
EXPECT_TRUE(selection.GetSelectedId().empty());
EXPECT_FALSE(selection.ClearSelection());
}
TEST(UISelectionModelTest, ToggleSelectionSelectsAndDeselectsMatchingId) {
UISelectionModel selection = {};
EXPECT_TRUE(selection.ToggleSelection("treeScenes"));
EXPECT_EQ(selection.GetSelectedId(), "treeScenes");
EXPECT_TRUE(selection.ToggleSelection("treeScenes"));
EXPECT_TRUE(selection.GetSelectedId().empty());
EXPECT_TRUE(selection.ToggleSelection("treeMaterials"));
EXPECT_EQ(selection.GetSelectedId(), "treeMaterials");
EXPECT_TRUE(selection.ToggleSelection("treeUi"));
EXPECT_EQ(selection.GetSelectedId(), "treeUi");
}
} // namespace

View File

@@ -0,0 +1,341 @@
#include <gtest/gtest.h>
#include <XCEngine/Resources/UI/UIDocumentTypes.h>
#include <XCEngine/UI/Style/DocumentStyleCompiler.h>
#include <XCEngine/UI/Style/StyleResolver.h>
namespace {
using XCEngine::Math::Color;
using XCEngine::Resources::UIDocumentAttribute;
using XCEngine::Resources::UIDocumentKind;
using XCEngine::Resources::UIDocumentModel;
using XCEngine::Resources::UIDocumentNode;
using XCEngine::UI::Style::BuildBuiltinTheme;
using XCEngine::UI::Style::BuildInlineStyle;
using XCEngine::UI::Style::BuildTheme;
using XCEngine::UI::Style::CompileDocumentStyle;
using XCEngine::UI::Style::UICornerRadius;
using XCEngine::UI::Style::UIBuiltinThemeKind;
using XCEngine::UI::Style::UIResolvedStyle;
using XCEngine::UI::Style::UIStyleLayer;
using XCEngine::UI::Style::UIStylePropertyId;
using XCEngine::UI::Style::UIStyleResolveContext;
using XCEngine::UI::Style::UIStyleSet;
using XCEngine::UI::Style::UIStyleSheet;
using XCEngine::UI::Style::UIStyleValue;
using XCEngine::UI::Style::UIStyleValueType;
using XCEngine::UI::Style::UITheme;
using XCEngine::UI::Style::UIThemeDefinition;
using XCEngine::UI::Style::UITokenResolveStatus;
void ExpectColorEq(const Color& actual, const Color& expected) {
EXPECT_FLOAT_EQ(actual.r, expected.r);
EXPECT_FLOAT_EQ(actual.g, expected.g);
EXPECT_FLOAT_EQ(actual.b, expected.b);
EXPECT_FLOAT_EQ(actual.a, expected.a);
}
UIDocumentAttribute MakeAttribute(const char* name, const char* value) {
UIDocumentAttribute attribute = {};
attribute.name = name;
attribute.value = value;
return attribute;
}
UIDocumentNode MakeNode(
const char* tagName,
std::initializer_list<std::pair<const char*, const char*>> attributes = {},
std::initializer_list<UIDocumentNode> children = {}) {
UIDocumentNode node = {};
node.tagName = tagName;
for (const auto& [name, value] : attributes) {
node.attributes.PushBack(MakeAttribute(name, value));
}
for (const UIDocumentNode& child : children) {
node.children.PushBack(child);
}
return node;
}
UIDocumentModel MakeThemeDocument() {
UIDocumentModel document = {};
document.kind = UIDocumentKind::Theme;
document.valid = true;
document.rootNode = MakeNode(
"Theme",
{ { "name", "ValidationTheme" } },
{
MakeNode(
"Tokens",
{},
{
MakeNode("Color", { { "name", "color.surface" }, { "value", "#202224" } }),
MakeNode("Color", { { "name", "color.border" }, { "value", "#45484C" } }),
MakeNode("Spacing", { { "name", "space.panel" }, { "value", "12" } }),
MakeNode("Radius", { { "name", "radius.card" }, { "value", "10" } })
}),
MakeNode(
"Widgets",
{},
{
MakeNode(
"Widget",
{ { "type", "Card" } },
{
MakeNode("Property", { { "name", "borderColor" }, { "value", "color.border" } }),
MakeNode("Property", { { "name", "borderWidth" }, { "value", "1" } })
}),
MakeNode(
"Widget",
{ { "type", "Card" }, { "style", "ValidationPanel" } },
{
MakeNode("Property", { { "name", "background" }, { "value", "color.surface" } }),
MakeNode("Property", { { "name", "padding" }, { "value", "space.panel" } }),
MakeNode("Property", { { "name", "radius" }, { "value", "radius.card" } })
}),
MakeNode(
"Widget",
{ { "type", "Text" } },
{
MakeNode("Property", { { "name", "foreground" }, { "value", "#E8E8E8" } }),
MakeNode("Property", { { "name", "fontSize" }, { "value", "15" } })
})
})
});
return document;
}
TEST(UI_StyleSystem, ThemeDefinitionBuildsThemeAndResolvesAliases) {
UIThemeDefinition definition = {};
definition.name = "CustomTheme";
definition.SetToken("space.base", UIStyleValue(8.0f));
definition.SetToken("gap.control", UIStyleValue::Token("space.base"));
definition.SetToken("radius.card", UIStyleValue(UICornerRadius::Uniform(12.0f)));
const UITheme theme = BuildTheme(definition);
EXPECT_EQ(theme.GetName(), "CustomTheme");
const auto gapToken = theme.ResolveToken("gap.control", UIStyleValueType::Float);
ASSERT_EQ(gapToken.status, UITokenResolveStatus::Resolved);
ASSERT_NE(gapToken.value.TryGetFloat(), nullptr);
EXPECT_FLOAT_EQ(*gapToken.value.TryGetFloat(), 8.0f);
const auto radiusToken = theme.ResolveToken("radius.card", UIStyleValueType::CornerRadius);
ASSERT_EQ(radiusToken.status, UITokenResolveStatus::Resolved);
ASSERT_NE(radiusToken.value.TryGetCornerRadius(), nullptr);
EXPECT_TRUE(radiusToken.value.TryGetCornerRadius()->IsUniform());
EXPECT_FLOAT_EQ(radiusToken.value.TryGetCornerRadius()->topLeft, 12.0f);
}
TEST(UI_StyleSystem, ThemeResolvesParentTokensAndBuiltinVariantsDiffer) {
UIThemeDefinition baseDefinition = {};
baseDefinition.name = "Base";
baseDefinition.SetToken("color.surface", UIStyleValue(Color(0.20f, 0.21f, 0.22f, 1.0f)));
const UITheme baseTheme = BuildTheme(baseDefinition);
UIThemeDefinition childDefinition = {};
childDefinition.name = "Child";
childDefinition.SetToken("font.body", UIStyleValue(15.0f));
const UITheme childTheme = BuildTheme(childDefinition, &baseTheme);
const auto parentToken = childTheme.ResolveToken("color.surface", UIStyleValueType::Color);
ASSERT_EQ(parentToken.status, UITokenResolveStatus::Resolved);
ASSERT_NE(parentToken.value.TryGetColor(), nullptr);
ExpectColorEq(*parentToken.value.TryGetColor(), Color(0.20f, 0.21f, 0.22f, 1.0f));
const UITheme darkTheme = BuildBuiltinTheme(UIBuiltinThemeKind::NeutralDark);
const UITheme lightTheme = BuildBuiltinTheme(UIBuiltinThemeKind::NeutralLight);
const auto darkSurface = darkTheme.ResolveToken("color.surface", UIStyleValueType::Color);
const auto lightSurface = lightTheme.ResolveToken("color.surface", UIStyleValueType::Color);
ASSERT_EQ(darkSurface.status, UITokenResolveStatus::Resolved);
ASSERT_EQ(lightSurface.status, UITokenResolveStatus::Resolved);
ASSERT_NE(darkSurface.value.TryGetColor(), nullptr);
ASSERT_NE(lightSurface.value.TryGetColor(), nullptr);
EXPECT_NE(darkSurface.value.TryGetColor()->r, lightSurface.value.TryGetColor()->r);
}
TEST(UI_StyleSystem, ThemeReportsMissingCyclesAndTypeMismatches) {
UIThemeDefinition definition = {};
definition.SetToken("cycle.a", UIStyleValue::Token("cycle.b"));
definition.SetToken("cycle.b", UIStyleValue::Token("cycle.a"));
definition.SetToken("color.surface", UIStyleValue(Color(0.1f, 0.2f, 0.3f, 1.0f)));
const UITheme theme = BuildTheme(definition);
EXPECT_EQ(
theme.ResolveToken("missing.token", UIStyleValueType::Float).status,
UITokenResolveStatus::MissingToken);
EXPECT_EQ(
theme.ResolveToken("cycle.a", UIStyleValueType::Float).status,
UITokenResolveStatus::CycleDetected);
EXPECT_EQ(
theme.ResolveToken("color.surface", UIStyleValueType::Float).status,
UITokenResolveStatus::TypeMismatch);
}
TEST(UI_StyleSystem, StyleResolutionPrefersLocalThenNamedThenTypeThenDefault) {
UIStyleSheet styleSheet = {};
styleSheet.DefaultStyle().SetProperty(UIStylePropertyId::FontSize, UIStyleValue(12.0f));
styleSheet.GetOrCreateTypeStyle("Button").SetProperty(UIStylePropertyId::FontSize, UIStyleValue(14.0f));
styleSheet.GetOrCreateNamedStyle("Primary").SetProperty(UIStylePropertyId::FontSize, UIStyleValue(16.0f));
UIStyleSet localStyle = {};
localStyle.SetProperty(UIStylePropertyId::FontSize, UIStyleValue(18.0f));
UIStyleResolveContext context = {};
context.styleSheet = &styleSheet;
context.selector.typeName = "Button";
context.selector.styleName = "Primary";
context.localStyle = &localStyle;
auto resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
ASSERT_TRUE(resolution.resolved);
EXPECT_EQ(resolution.layer, UIStyleLayer::Local);
ASSERT_NE(resolution.value.TryGetFloat(), nullptr);
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 18.0f);
localStyle.RemoveProperty(UIStylePropertyId::FontSize);
resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
ASSERT_TRUE(resolution.resolved);
EXPECT_EQ(resolution.layer, UIStyleLayer::Named);
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 16.0f);
styleSheet.GetOrCreateNamedStyle("Primary").RemoveProperty(UIStylePropertyId::FontSize);
resolution = XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::FontSize, context);
ASSERT_TRUE(resolution.resolved);
EXPECT_EQ(resolution.layer, UIStyleLayer::Type);
EXPECT_FLOAT_EQ(*resolution.value.TryGetFloat(), 14.0f);
}
TEST(UI_StyleSystem, StyleResolutionFallsBackWhenHigherPriorityTokenCannotResolve) {
UIThemeDefinition themeDefinition = {};
themeDefinition.SetToken("color.accent", UIStyleValue(Color(0.90f, 0.20f, 0.10f, 1.0f)));
const UITheme theme = BuildTheme(themeDefinition);
UIStyleSheet styleSheet = {};
styleSheet.DefaultStyle().SetProperty(UIStylePropertyId::BorderWidth, UIStyleValue(1.0f));
styleSheet.GetOrCreateTypeStyle("Button")
.SetProperty(UIStylePropertyId::BackgroundColor, UIStyleValue::Token("color.accent"));
styleSheet.GetOrCreateNamedStyle("Danger")
.SetProperty(UIStylePropertyId::BackgroundColor, UIStyleValue::Token("missing.token"));
UIStyleResolveContext context = {};
context.theme = &theme;
context.styleSheet = &styleSheet;
context.selector.typeName = "Button";
context.selector.styleName = "Danger";
const auto backgroundResolution =
XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::BackgroundColor, context);
ASSERT_TRUE(backgroundResolution.resolved);
EXPECT_EQ(backgroundResolution.layer, UIStyleLayer::Type);
ASSERT_NE(backgroundResolution.value.TryGetColor(), nullptr);
ExpectColorEq(*backgroundResolution.value.TryGetColor(), Color(0.90f, 0.20f, 0.10f, 1.0f));
const UIResolvedStyle resolvedStyle = XCEngine::UI::Style::ResolveStyle(context);
const auto* borderWidthResolution = resolvedStyle.FindProperty(UIStylePropertyId::BorderWidth);
ASSERT_NE(borderWidthResolution, nullptr);
EXPECT_EQ(borderWidthResolution->layer, UIStyleLayer::Default);
ASSERT_NE(borderWidthResolution->value.TryGetFloat(), nullptr);
EXPECT_FLOAT_EQ(*borderWidthResolution->value.TryGetFloat(), 1.0f);
}
TEST(UI_StyleSystem, StyleResolutionCoercesUniformFloatValuesForPaddingAndRadius) {
UIThemeDefinition themeDefinition = {};
themeDefinition.SetToken("space.compact", UIStyleValue(10.0f));
themeDefinition.SetToken("radius.control", UIStyleValue(6.0f));
const UITheme theme = BuildTheme(themeDefinition);
UIStyleSheet styleSheet = {};
styleSheet.GetOrCreateNamedStyle("Chip")
.SetProperty(UIStylePropertyId::Padding, UIStyleValue::Token("space.compact"));
styleSheet.GetOrCreateNamedStyle("Chip")
.SetProperty(UIStylePropertyId::CornerRadius, UIStyleValue::Token("radius.control"));
UIStyleResolveContext context = {};
context.theme = &theme;
context.styleSheet = &styleSheet;
context.selector.typeName = "Button";
context.selector.styleName = "Chip";
const auto paddingResolution =
XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::Padding, context);
ASSERT_TRUE(paddingResolution.resolved);
ASSERT_NE(paddingResolution.value.TryGetThickness(), nullptr);
EXPECT_TRUE(paddingResolution.value.TryGetThickness()->IsUniform());
EXPECT_FLOAT_EQ(paddingResolution.value.TryGetThickness()->left, 10.0f);
const auto radiusResolution =
XCEngine::UI::Style::ResolveStyleProperty(UIStylePropertyId::CornerRadius, context);
ASSERT_TRUE(radiusResolution.resolved);
ASSERT_NE(radiusResolution.value.TryGetCornerRadius(), nullptr);
EXPECT_TRUE(radiusResolution.value.TryGetCornerRadius()->IsUniform());
EXPECT_FLOAT_EQ(radiusResolution.value.TryGetCornerRadius()->topLeft, 6.0f);
}
TEST(UI_StyleSystem, DocumentThemeCompilerBuildsThemeSheetAndInlineStyles) {
const UIDocumentModel document = MakeThemeDocument();
const auto compiled = CompileDocumentStyle(document);
ASSERT_TRUE(compiled.succeeded);
EXPECT_EQ(compiled.theme.GetName(), "ValidationTheme");
const auto surfaceToken = compiled.theme.ResolveToken("color.surface", UIStyleValueType::Color);
ASSERT_EQ(surfaceToken.status, UITokenResolveStatus::Resolved);
ASSERT_NE(surfaceToken.value.TryGetColor(), nullptr);
ExpectColorEq(*surfaceToken.value.TryGetColor(), Color(0x20 / 255.0f, 0x22 / 255.0f, 0x24 / 255.0f, 1.0f));
UIStyleResolveContext context = {};
context.theme = &compiled.theme;
context.styleSheet = &compiled.styleSheet;
context.selector.typeName = "Card";
context.selector.styleName = "ValidationPanel";
const UIResolvedStyle resolvedStyle = XCEngine::UI::Style::ResolveStyle(context);
const auto* background = resolvedStyle.FindProperty(UIStylePropertyId::BackgroundColor);
ASSERT_NE(background, nullptr);
EXPECT_EQ(background->layer, UIStyleLayer::Named);
ASSERT_NE(background->value.TryGetColor(), nullptr);
ExpectColorEq(*background->value.TryGetColor(), Color(0x20 / 255.0f, 0x22 / 255.0f, 0x24 / 255.0f, 1.0f));
const auto* padding = resolvedStyle.FindProperty(UIStylePropertyId::Padding);
ASSERT_NE(padding, nullptr);
EXPECT_EQ(padding->layer, UIStyleLayer::Named);
ASSERT_NE(padding->value.TryGetThickness(), nullptr);
EXPECT_TRUE(padding->value.TryGetThickness()->IsUniform());
EXPECT_FLOAT_EQ(padding->value.TryGetThickness()->left, 12.0f);
const auto* borderColor = resolvedStyle.FindProperty(UIStylePropertyId::BorderColor);
ASSERT_NE(borderColor, nullptr);
EXPECT_EQ(borderColor->layer, UIStyleLayer::Type);
ASSERT_NE(borderColor->value.TryGetColor(), nullptr);
ExpectColorEq(*borderColor->value.TryGetColor(), Color(0x45 / 255.0f, 0x48 / 255.0f, 0x4C / 255.0f, 1.0f));
const UIDocumentNode buttonNode = MakeNode(
"Button",
{
{ "background", "#2E3135" },
{ "padding", "18" },
{ "radius", "8" },
{ "fontSize", "16" }
});
const UIStyleSet inlineStyle = BuildInlineStyle(buttonNode);
UIStyleResolveContext inlineContext = {};
inlineContext.localStyle = &inlineStyle;
const UIResolvedStyle inlineResolved = XCEngine::UI::Style::ResolveStyle(inlineContext);
const auto* inlinePadding = inlineResolved.FindProperty(UIStylePropertyId::Padding);
ASSERT_NE(inlinePadding, nullptr);
ASSERT_NE(inlinePadding->value.TryGetThickness(), nullptr);
EXPECT_TRUE(inlinePadding->value.TryGetThickness()->IsUniform());
EXPECT_FLOAT_EQ(inlinePadding->value.TryGetThickness()->left, 18.0f);
const auto* inlineRadius = inlineResolved.FindProperty(UIStylePropertyId::CornerRadius);
ASSERT_NE(inlineRadius, nullptr);
ASSERT_NE(inlineRadius->value.TryGetCornerRadius(), nullptr);
EXPECT_TRUE(inlineRadius->value.TryGetCornerRadius()->IsUniform());
EXPECT_FLOAT_EQ(inlineRadius->value.TryGetCornerRadius()->topLeft, 8.0f);
}
} // namespace

View File

@@ -0,0 +1,59 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Text/UITextEditing.h>
#include <string>
namespace {
namespace UIText = XCEngine::UI::Text;
TEST(UITextEditingTest, Utf8CountingAndCaretOffsetsRespectCodepointBoundaries) {
const std::string text = std::string("A") + "\xE4\xBD\xA0" + "B";
EXPECT_EQ(UIText::CountUtf8Codepoints(text), 3u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 0u), 1u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 1u), 4u);
EXPECT_EQ(UIText::AdvanceUtf8Offset(text, 4u), 5u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, text.size()), 4u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, 4u), 1u);
EXPECT_EQ(UIText::RetreatUtf8Offset(text, 1u), 0u);
}
TEST(UITextEditingTest, AppendUtf8CodepointEncodesCharactersAndSkipsInvalidSurrogates) {
std::string text = {};
UIText::AppendUtf8Codepoint(text, 'A');
UIText::AppendUtf8Codepoint(text, 0x4F60u);
UIText::AppendUtf8Codepoint(text, 0x1F642u);
UIText::AppendUtf8Codepoint(text, 0xD800u);
EXPECT_EQ(text, std::string("A") + "\xE4\xBD\xA0" + "\xF0\x9F\x99\x82");
EXPECT_EQ(UIText::CountUtf8Codepoints(text), 3u);
}
TEST(UITextEditingTest, SplitLinesAndLineHelpersTrackMultilineRanges) {
const std::string text = "alpha\nbeta\n";
const auto lines = UIText::SplitLines(text);
ASSERT_EQ(lines.size(), 3u);
EXPECT_EQ(lines[0], "alpha");
EXPECT_EQ(lines[1], "beta");
EXPECT_EQ(lines[2], "");
EXPECT_EQ(UIText::CountTextLines(text), 3u);
EXPECT_EQ(UIText::CountUtf8CodepointsInRange(text, 6u, 10u), 4u);
EXPECT_EQ(UIText::FindLineStartOffset(text, 7u), 6u);
EXPECT_EQ(UIText::FindLineEndOffset(text, 7u), 10u);
}
TEST(UITextEditingTest, MoveCaretVerticallyPreservesUtf8ColumnWhenPossible) {
const std::string text = std::string("A") + "\xE4\xBD\xA0" + "Z\nBC\n";
const std::size_t secondColumnCaret = UIText::AdvanceUtf8Offset(text, 1u);
const std::size_t movedDown = UIText::MoveCaretVertically(text, secondColumnCaret, 1);
const std::size_t movedBackUp = UIText::MoveCaretVertically(text, movedDown, -1);
EXPECT_EQ(movedDown, 8u);
EXPECT_EQ(movedBackUp, secondColumnCaret);
}
} // namespace

View File

@@ -0,0 +1,287 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Text/UITextInputController.h>
namespace {
namespace UIText = XCEngine::UI::Text;
using XCEngine::Input::KeyCode;
TEST(UITextInputControllerTest, InsertCharacterTracksUtf8CaretMovement) {
UIText::UITextInputState state = {};
EXPECT_TRUE(UIText::InsertCharacter(state, 'A'));
EXPECT_TRUE(UIText::InsertCharacter(state, 0x4F60u));
EXPECT_EQ(state.value, std::string("A") + "\xE4\xBD\xA0");
EXPECT_EQ(state.caret, state.value.size());
}
TEST(UITextInputControllerTest, BackspaceAndArrowKeysUseUtf8Boundaries) {
UIText::UITextInputState state = {};
state.value = std::string("A") + "\xE4\xBD\xA0" + "B";
state.caret = state.value.size();
const auto moveLeft = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Left),
{},
{});
EXPECT_TRUE(moveLeft.handled);
EXPECT_EQ(state.caret, 4u);
const auto backspace = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Backspace),
{},
{});
EXPECT_TRUE(backspace.handled);
EXPECT_TRUE(backspace.valueChanged);
EXPECT_EQ(state.value, "AB");
EXPECT_EQ(state.caret, 1u);
}
TEST(UITextInputControllerTest, DeleteUsesUtf8BoundariesAndLeavesCaretAtDeletePoint) {
if (static_cast<std::int32_t>(KeyCode::Delete) ==
static_cast<std::int32_t>(KeyCode::Backspace)) {
GTEST_SKIP() << "KeyCode::Delete currently aliases Backspace.";
}
UIText::UITextInputState state = {};
state.value = std::string("A") + "\xE4\xBD\xA0" + "B";
state.caret = 1u;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Delete),
{},
{});
EXPECT_TRUE(result.handled);
EXPECT_TRUE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "AB");
EXPECT_EQ(state.caret, 1u);
}
TEST(UITextInputControllerTest, DeleteClampsOversizedCaretAndDoesNotMutateAtDocumentEnd) {
if (static_cast<std::int32_t>(KeyCode::Delete) ==
static_cast<std::int32_t>(KeyCode::Backspace)) {
GTEST_SKIP() << "KeyCode::Delete currently aliases Backspace.";
}
UIText::UITextInputState state = {};
state.value = "AB";
state.caret = 99u;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Delete),
{},
{});
EXPECT_TRUE(result.handled);
EXPECT_FALSE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "AB");
EXPECT_EQ(state.caret, state.value.size());
}
TEST(UITextInputControllerTest, SingleLineEnterRequestsSubmitWithoutMutatingValue) {
UIText::UITextInputState state = {};
state.value = "prompt";
state.caret = state.value.size();
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Enter),
{},
{});
EXPECT_TRUE(result.handled);
EXPECT_FALSE(result.valueChanged);
EXPECT_TRUE(result.submitRequested);
EXPECT_EQ(state.value, "prompt");
}
TEST(UITextInputControllerTest, MultilineEnterAndVerticalMovementStayInController) {
UIText::UITextInputState state = {};
state.value = std::string("A") + "\xE4\xBD\xA0" + "Z\nBC";
state.caret = 4u;
const UIText::UITextInputOptions options = { true, 4u };
const auto moveDown = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Down),
{},
options);
EXPECT_TRUE(moveDown.handled);
EXPECT_EQ(state.caret, 8u);
const auto enter = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Enter),
{},
options);
EXPECT_TRUE(enter.handled);
EXPECT_TRUE(enter.valueChanged);
EXPECT_EQ(state.value, std::string("A") + "\xE4\xBD\xA0" + "Z\nBC\n");
}
TEST(UITextInputControllerTest, HomeAndEndRespectSingleLineAndMultilineBounds) {
UIText::UITextInputState singleLine = {};
singleLine.value = "prompt";
singleLine.caret = 2u;
const auto singleHome = UIText::HandleKeyDown(
singleLine,
static_cast<std::int32_t>(KeyCode::Home),
{},
{});
EXPECT_TRUE(singleHome.handled);
EXPECT_EQ(singleLine.caret, 0u);
const auto singleEnd = UIText::HandleKeyDown(
singleLine,
static_cast<std::int32_t>(KeyCode::End),
{},
{});
EXPECT_TRUE(singleEnd.handled);
EXPECT_EQ(singleLine.caret, singleLine.value.size());
UIText::UITextInputState multiline = {};
multiline.value = "root\nleaf\nend";
multiline.caret = 7u;
const UIText::UITextInputOptions options = { true, 4u };
const auto multilineHome = UIText::HandleKeyDown(
multiline,
static_cast<std::int32_t>(KeyCode::Home),
{},
options);
EXPECT_TRUE(multilineHome.handled);
EXPECT_EQ(multiline.caret, 5u);
multiline.caret = 7u;
const auto multilineEnd = UIText::HandleKeyDown(
multiline,
static_cast<std::int32_t>(KeyCode::End),
{},
options);
EXPECT_TRUE(multilineEnd.handled);
EXPECT_EQ(multiline.caret, 9u);
}
TEST(UITextInputControllerTest, ClampCaretAndInsertCharacterRecoverFromOversizedCaret) {
UIText::UITextInputState state = {};
state.value = "go";
state.caret = 42u;
UIText::ClampCaret(state);
EXPECT_EQ(state.caret, state.value.size());
state.caret = 42u;
EXPECT_TRUE(UIText::InsertCharacter(state, '!'));
EXPECT_EQ(state.value, "go!");
EXPECT_EQ(state.caret, state.value.size());
}
TEST(UITextInputControllerTest, MultilineTabAndShiftTabIndentAndOutdentCurrentLine) {
UIText::UITextInputState state = {};
state.value = "root\nnode";
state.caret = 5u;
const UIText::UITextInputOptions options = { true, 4u };
const auto indent = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Tab),
{},
options);
EXPECT_TRUE(indent.handled);
EXPECT_TRUE(indent.valueChanged);
EXPECT_EQ(state.value, "root\n node");
EXPECT_EQ(state.caret, 9u);
XCEngine::UI::UIInputModifiers modifiers = {};
modifiers.shift = true;
const auto outdent = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Tab),
modifiers,
options);
EXPECT_TRUE(outdent.handled);
EXPECT_TRUE(outdent.valueChanged);
EXPECT_EQ(state.value, "root\nnode");
EXPECT_EQ(state.caret, 5u);
}
TEST(UITextInputControllerTest, ShiftTabWithoutLeadingSpacesIsHandledWithoutMutatingText) {
UIText::UITextInputState state = {};
state.value = "root\nnode";
state.caret = 5u;
XCEngine::UI::UIInputModifiers modifiers = {};
modifiers.shift = true;
const auto result = UIText::HandleKeyDown(
state,
static_cast<std::int32_t>(KeyCode::Tab),
modifiers,
{ true, 4u });
EXPECT_TRUE(result.handled);
EXPECT_FALSE(result.valueChanged);
EXPECT_FALSE(result.submitRequested);
EXPECT_EQ(state.value, "root\nnode");
EXPECT_EQ(state.caret, 5u);
}
TEST(UITextInputControllerTest, MultilineTabIgnoresSystemModifiers) {
const auto buildState = []() {
UIText::UITextInputState state = {};
state.value = "root\nnode";
state.caret = 5u;
return state;
};
const UIText::UITextInputOptions options = { true, 4u };
XCEngine::UI::UIInputModifiers control = {};
control.control = true;
auto controlState = buildState();
const auto controlResult = UIText::HandleKeyDown(
controlState,
static_cast<std::int32_t>(KeyCode::Tab),
control,
options);
EXPECT_FALSE(controlResult.handled);
EXPECT_FALSE(controlResult.valueChanged);
EXPECT_EQ(controlState.value, "root\nnode");
EXPECT_EQ(controlState.caret, 5u);
XCEngine::UI::UIInputModifiers alt = {};
alt.alt = true;
auto altState = buildState();
const auto altResult = UIText::HandleKeyDown(
altState,
static_cast<std::int32_t>(KeyCode::Tab),
alt,
options);
EXPECT_FALSE(altResult.handled);
EXPECT_FALSE(altResult.valueChanged);
EXPECT_EQ(altState.value, "root\nnode");
EXPECT_EQ(altState.caret, 5u);
XCEngine::UI::UIInputModifiers superModifier = {};
superModifier.super = true;
auto superState = buildState();
const auto superResult = UIText::HandleKeyDown(
superState,
static_cast<std::int32_t>(KeyCode::Tab),
superModifier,
options);
EXPECT_FALSE(superResult.handled);
EXPECT_FALSE(superResult.valueChanged);
EXPECT_EQ(superState.value, "root\nnode");
EXPECT_EQ(superState.caret, 5u);
}
} // namespace

View File

@@ -2,7 +2,6 @@ cmake_minimum_required(VERSION 3.15)
project(XCEngine_EditorUITests) project(XCEngine_EditorUITests)
add_subdirectory(integration/shared)
add_subdirectory(unit) add_subdirectory(unit)
add_subdirectory(integration) add_subdirectory(integration)

View File

@@ -1,8 +1,11 @@
add_subdirectory(input) file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH)
add_subdirectory(layout)
add_subdirectory(shared)
add_subdirectory(workspace_shell_compose)
add_subdirectory(state)
add_custom_target(editor_ui_integration_tests add_custom_target(editor_ui_integration_tests
DEPENDS DEPENDS
editor_ui_input_integration_tests editor_ui_workspace_shell_compose_validation
editor_ui_layout_integration_tests editor_ui_panel_session_flow_validation
) )

View File

@@ -1,19 +1,56 @@
# Editor UI Integration Validation # Editor UI Integration Validation
This directory contains the manual XCUI validation system for editor-facing scenarios. This directory contains editor-only XCUI manual validation scenarios.
Structure: Current status:
- `shared/`: shared host, native renderer, screenshot helper, scenario registry - Shared Core primitives remain in `tests/UI/Core/integration/`.
- `input/`: input-related validation category - Only editor-only host, shell, widget, and domain-integrated validation should live here.
- `layout/`: layout and shell-foundation validation category - The first authored scenario is `workspace_shell_compose/`, focused on shell compose only:
splitters, tab host, panel chrome placeholders, and hot reload.
- The second scenario is `state/panel_session_flow/`, focused on editor command dispatch and panel session state only:
`command dispatch + workspace controller + open / close / show / hide / activate`.
Rules: Layout:
- One scenario directory maps to one executable. - `shared/`: editor validation scenario registry, Win32 host wrapper, shared theme
- Do not accumulate unrelated checks into one monolithic app. - `workspace_shell_compose/`: first manual editor shell compose scenario
- Shared infrastructure belongs in `shared/`, not duplicated per scenario. - `state/panel_session_flow/`: custom host scenario for editor panel session state flow
- Screenshots are stored per scenario inside that scenario's `captures/` folder.
Current scenario:
- Scenario id: `editor.shell.workspace_compose`
- Build target: `editor_ui_workspace_shell_compose_validation`
- Executable name: `XCUIEditorWorkspaceShellComposeValidation`
- Validation scope: split/tab/panel shell compose only, no business panels
Additional scenario:
- Scenario id: `editor.state.panel_session_flow`
- Build target: `editor_ui_panel_session_flow_validation`
- Executable name: `XCUIEditorPanelSessionFlowValidation`
- Validation scope: editor command dispatch and panel session state only, no business panels
Run:
```bash
cmake --build build --config Debug --target editor_ui_workspace_shell_compose_validation
```
Then launch `XCUIEditorWorkspaceShellComposeValidation.exe` from the build output, or run it from your IDE by target name.
Controls:
- Drag authored splitters to verify live resize and min clamps.
- Click `Document A/B/C` to verify only the selected tab placeholder is visible.
- Press `F12` to write screenshots into `workspace_shell_compose/captures/`.
- Authored `.xcui` and `.xctheme` changes hot reload while the host is running.
Panel session flow controls:
- Click `Hide Active / Show Doc A / Close Doc B / Open Doc B / Activate Details / Reset`.
- Check `Last command` shows `Changed / NoOp / Rejected` consistently with the current state.
- Press `F12` to write screenshots into `state/panel_session_flow/captures/`.
Build: Build:

View File

@@ -1,12 +0,0 @@
add_subdirectory(keyboard_focus)
add_subdirectory(pointer_states)
add_subdirectory(scroll_view)
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_scroll_view_validation
editor_ui_input_shortcut_scope_validation
)

View File

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

View File

@@ -1,35 +0,0 @@
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

@@ -1,18 +0,0 @@
# 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

@@ -1,35 +0,0 @@
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

@@ -1,17 +0,0 @@
# 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

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

View File

@@ -1,35 +0,0 @@
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

@@ -1,10 +0,0 @@
add_subdirectory(splitter_resize)
add_subdirectory(tab_strip_selection)
add_subdirectory(workspace_compose)
add_custom_target(editor_ui_layout_integration_tests
DEPENDS
editor_ui_layout_splitter_resize_validation
editor_ui_layout_tab_strip_selection_validation
editor_ui_layout_workspace_compose_validation
)

View File

@@ -1,35 +0,0 @@
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)

Some files were not shown because too many files have changed in this diff Show More