diff --git a/docs/plan/xcui-subplans/Subplan-03_XCUI-Style-Theme-Token.md b/docs/plan/xcui-subplans/Subplan-03_XCUI-Style-Theme-Token.md new file mode 100644 index 00000000..5217fdaa --- /dev/null +++ b/docs/plan/xcui-subplans/Subplan-03_XCUI-Style-Theme-Token.md @@ -0,0 +1,46 @@ +# Subplan 03:XCUI Style / Theme / Token + +目标: + +- 建立 XCUI 的样式模型、token 体系与主题覆盖规则。 +- 让控件视觉不再散落在代码硬编码常量里。 + +负责人边界: + +- 负责 `Style` / `Theme` / `Token` 数据模型和解析规则。 +- 不负责 `.xcui` 导入器。 +- 不负责最终 renderer 具体绘制。 + +建议目录: + +- `engine/include/XCEngine/UI/Style/` +- `engine/src/UI/Style/` +- `tests` 中 style/theme 测试 + +前置依赖: + +- 依赖 `Subplan 01` 的节点属性注入点。 + +现在就可以先做的内容: + +- 设计 style 层级:默认样式、类型样式、命名样式、局部覆盖 +- 设计 token:颜色、圆角、边距、字号、线宽 +- 设计主题切换与 token 查询接口 +- 写冲突优先级测试 + +明确不做: + +- 不做具体面板视觉重构 +- 不做字体资源导入 + +交付物: + +- 统一样式查询入口 +- `.xctheme` 对应的数据结构 +- 样式优先级与 token 解析测试 + +验收标准: + +- 样式优先级可预测 +- 主题替换不需要改控件逻辑 +- 其他 subplan 能通过统一 API 获取视觉参数 diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 439047ae..5ad164bd 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -413,7 +413,14 @@ add_library(XCEngine STATIC ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIElementTree.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIContext.h ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Core/UIBuildContext.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Core/UIElementTree.cpp + /src/UI/Core/UIElementTree.cpp + /include/XCEngine/UI/Style/StyleTypes.h + /include/XCEngine/UI/Style/Theme.h + /include/XCEngine/UI/Style/StyleSet.h + /include/XCEngine/UI/Style/StyleResolver.h + /src/UI/Style/StyleTypes.cpp + /src/UI/Style/Theme.cpp + /src/UI/Style/StyleResolver.cpp # Input ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h diff --git a/engine/include/XCEngine/UI/Style/StyleResolver.h b/engine/include/XCEngine/UI/Style/StyleResolver.h new file mode 100644 index 00000000..a29e705c --- /dev/null +++ b/engine/include/XCEngine/UI/Style/StyleResolver.h @@ -0,0 +1,61 @@ +#pragma once + +#include "StyleSet.h" +#include "Theme.h" + +#include + +namespace XCEngine { +namespace UI { +namespace Style { + +enum class UIStyleLayer : std::uint8_t { + None = 0, + Default, + Type, + Named, + Local +}; + +struct UIStylePropertyResolution { + bool resolved = false; + UIStyleLayer layer = UIStyleLayer::None; + UIStyleValue value = {}; +}; + +class UIResolvedStyle { +public: + void SetProperty(UIStylePropertyId propertyId, const UIStylePropertyResolution& resolution) { + m_properties[propertyId] = resolution; + } + + const UIStylePropertyResolution* FindProperty(UIStylePropertyId propertyId) const { + const auto it = m_properties.find(propertyId); + return it != m_properties.end() ? &it->second : nullptr; + } + + const std::map& GetProperties() const { + return m_properties; + } + +private: + std::map m_properties = {}; +}; + +struct UIStyleResolveContext { + const UITheme* theme = nullptr; + const UIStyleSheet* styleSheet = nullptr; + UIStyleSelector selector = {}; + const UIStyleSet* localStyle = nullptr; +}; + +UIStylePropertyResolution ResolveStyleProperty( + UIStylePropertyId propertyId, + const UIStyleResolveContext& context); + +UIResolvedStyle ResolveStyle(const UIStyleResolveContext& context); +const char* ToString(UIStyleLayer layer); + +} // namespace Style +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Style/StyleSet.h b/engine/include/XCEngine/UI/Style/StyleSet.h new file mode 100644 index 00000000..b9627ee9 --- /dev/null +++ b/engine/include/XCEngine/UI/Style/StyleSet.h @@ -0,0 +1,80 @@ +#pragma once + +#include "StyleTypes.h" + +#include +#include + +namespace XCEngine { +namespace UI { +namespace Style { + +class UIStyleSet { +public: + void SetProperty(UIStylePropertyId propertyId, const UIStyleValue& value) { + m_properties[propertyId] = value; + } + + bool RemoveProperty(UIStylePropertyId propertyId) { + return m_properties.erase(propertyId) > 0u; + } + + bool HasProperty(UIStylePropertyId propertyId) const { + return m_properties.find(propertyId) != m_properties.end(); + } + + const UIStyleValue* FindProperty(UIStylePropertyId propertyId) const { + const auto it = m_properties.find(propertyId); + return it != m_properties.end() ? &it->second : nullptr; + } + + const std::map& GetProperties() const { + return m_properties; + } + +private: + std::map m_properties = {}; +}; + +struct UIStyleSelector { + std::string typeName; + std::string styleName; +}; + +class UIStyleSheet { +public: + UIStyleSet& DefaultStyle() { + return m_defaultStyle; + } + + const UIStyleSet& DefaultStyle() const { + return m_defaultStyle; + } + + UIStyleSet& GetOrCreateTypeStyle(const std::string& typeName) { + return m_typeStyles[typeName]; + } + + UIStyleSet& GetOrCreateNamedStyle(const std::string& styleName) { + return m_namedStyles[styleName]; + } + + const UIStyleSet* FindTypeStyle(const std::string& typeName) const { + const auto it = m_typeStyles.find(typeName); + return it != m_typeStyles.end() ? &it->second : nullptr; + } + + const UIStyleSet* FindNamedStyle(const std::string& styleName) const { + const auto it = m_namedStyles.find(styleName); + return it != m_namedStyles.end() ? &it->second : nullptr; + } + +private: + UIStyleSet m_defaultStyle = {}; + std::map m_typeStyles = {}; + std::map m_namedStyles = {}; +}; + +} // namespace Style +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Style/StyleTypes.h b/engine/include/XCEngine/UI/Style/StyleTypes.h new file mode 100644 index 00000000..a5826f91 --- /dev/null +++ b/engine/include/XCEngine/UI/Style/StyleTypes.h @@ -0,0 +1,104 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace XCEngine { +namespace UI { +namespace Style { + +struct UIThickness { + float left = 0.0f; + float top = 0.0f; + float right = 0.0f; + float bottom = 0.0f; + + static UIThickness Uniform(float value); + bool IsUniform() const; +}; + +struct UICornerRadius { + float topLeft = 0.0f; + float topRight = 0.0f; + float bottomRight = 0.0f; + float bottomLeft = 0.0f; + + static UICornerRadius Uniform(float value); + bool IsUniform() const; +}; + +struct UITokenReference { + std::string name; + + bool IsValid() const; +}; + +enum class UIStyleValueType : std::uint8_t { + None = 0, + Float, + Color, + Thickness, + CornerRadius, + Size, + Point, + TokenReference +}; + +class UIStyleValue { +public: + using Storage = + std::variant; + + UIStyleValue() = default; + explicit UIStyleValue(float value); + explicit UIStyleValue(const Math::Color& value); + explicit UIStyleValue(const UIThickness& value); + explicit UIStyleValue(const UICornerRadius& value); + explicit UIStyleValue(const UISize& value); + explicit UIStyleValue(const UIPoint& value); + + static UIStyleValue Token(const std::string& tokenName); + + bool IsSet() const; + bool IsTokenReference() const; + UIStyleValueType GetType() const; + + const float* TryGetFloat() const; + const Math::Color* TryGetColor() const; + const UIThickness* TryGetThickness() const; + const UICornerRadius* TryGetCornerRadius() const; + const UISize* TryGetSize() const; + const UIPoint* TryGetPoint() const; + const UITokenReference* TryGetTokenReference() const; + + bool operator==(const UIStyleValue& other) const; + bool operator!=(const UIStyleValue& other) const; + +private: + explicit UIStyleValue(const UITokenReference& tokenReference); + + Storage m_storage = {}; +}; + +enum class UIStylePropertyId : std::uint8_t { + BackgroundColor = 0, + ForegroundColor, + BorderColor, + BorderWidth, + CornerRadius, + Padding, + Gap, + FontSize, + LineWidth +}; + +UIStyleValueType GetExpectedValueType(UIStylePropertyId propertyId); +const char* ToString(UIStylePropertyId propertyId); + +} // namespace Style +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Style/Theme.h b/engine/include/XCEngine/UI/Style/Theme.h new file mode 100644 index 00000000..07248403 --- /dev/null +++ b/engine/include/XCEngine/UI/Style/Theme.h @@ -0,0 +1,75 @@ +#pragma once + +#include "StyleTypes.h" + +#include +#include +#include + +namespace XCEngine { +namespace UI { +namespace Style { + +enum class UITokenResolveStatus : std::uint8_t { + Unresolved = 0, + Resolved, + MissingToken, + CycleDetected, + TypeMismatch +}; + +struct UITokenResolveResult { + UITokenResolveStatus status = UITokenResolveStatus::Unresolved; + UIStyleValue value = {}; +}; + +struct UIThemeDefinition { + std::string name; + std::map tokens = {}; + + void SetToken(const std::string& tokenName, const UIStyleValue& value); + const UIStyleValue* FindToken(const std::string& tokenName) const; +}; + +enum class UIBuiltinThemeKind : std::uint8_t { + NeutralDark = 0, + NeutralLight +}; + +class UITheme { +public: + UITheme() = default; + explicit UITheme(const UIThemeDefinition& definition); + + void SetName(const std::string& name); + const std::string& GetName() const; + + void SetParent(const UITheme* parent); + const UITheme* GetParent() const; + + void SetToken(const std::string& tokenName, const UIStyleValue& value); + bool RemoveToken(const std::string& tokenName); + const UIStyleValue* FindToken(const std::string& tokenName) const; + const std::map& GetTokens() const; + + UITokenResolveResult ResolveToken( + const std::string& tokenName, + UIStyleValueType expectedType = UIStyleValueType::None) const; + +private: + UITokenResolveResult ResolveTokenRecursive( + const std::string& tokenName, + UIStyleValueType expectedType, + std::vector& visiting) const; + + std::string m_name; + const UITheme* m_parent = nullptr; + std::map m_tokens = {}; +}; + +UITheme BuildTheme(const UIThemeDefinition& definition, const UITheme* parent = nullptr); +UITheme BuildBuiltinTheme(UIBuiltinThemeKind themeKind); + +} // namespace Style +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Style/StyleResolver.cpp b/engine/src/UI/Style/StyleResolver.cpp new file mode 100644 index 00000000..909c56fc --- /dev/null +++ b/engine/src/UI/Style/StyleResolver.cpp @@ -0,0 +1,139 @@ +#include + +namespace XCEngine { +namespace UI { +namespace Style { + +namespace { + +bool TryResolveAssignedValue( + const UIStyleValue& assignedValue, + UIStyleValueType expectedType, + const UITheme* theme, + UIStyleValue& outValue) { + if (!assignedValue.IsSet()) { + return false; + } + + if (const UITokenReference* tokenReference = assignedValue.TryGetTokenReference()) { + if (theme == nullptr || !tokenReference->IsValid()) { + return false; + } + + const UITokenResolveResult tokenResult = theme->ResolveToken(tokenReference->name, expectedType); + if (tokenResult.status != UITokenResolveStatus::Resolved) { + return false; + } + + outValue = tokenResult.value; + return true; + } + + if (expectedType != UIStyleValueType::None && assignedValue.GetType() != expectedType) { + return false; + } + + outValue = assignedValue; + return true; +} + +const UIStyleSet* GetStyleSetForLayer(UIStyleLayer layer, const UIStyleResolveContext& context) { + if (context.styleSheet == nullptr) { + return layer == UIStyleLayer::Local ? context.localStyle : nullptr; + } + + switch (layer) { + case UIStyleLayer::Default: + return &context.styleSheet->DefaultStyle(); + case UIStyleLayer::Type: + return context.styleSheet->FindTypeStyle(context.selector.typeName); + case UIStyleLayer::Named: + return context.styleSheet->FindNamedStyle(context.selector.styleName); + case UIStyleLayer::Local: + return context.localStyle; + default: + return nullptr; + } +} + +} // namespace + +UIStylePropertyResolution ResolveStyleProperty( + UIStylePropertyId propertyId, + const UIStyleResolveContext& context) { + UIStylePropertyResolution resolution = {}; + const UIStyleValueType expectedType = GetExpectedValueType(propertyId); + const UIStyleLayer lookupOrder[] = { + UIStyleLayer::Local, + UIStyleLayer::Named, + UIStyleLayer::Type, + UIStyleLayer::Default + }; + + for (const UIStyleLayer layer : lookupOrder) { + const UIStyleSet* styleSet = GetStyleSetForLayer(layer, context); + if (styleSet == nullptr) { + continue; + } + + const UIStyleValue* assignedValue = styleSet->FindProperty(propertyId); + if (assignedValue == nullptr) { + continue; + } + + UIStyleValue resolvedValue = {}; + if (!TryResolveAssignedValue(*assignedValue, expectedType, context.theme, resolvedValue)) { + continue; + } + + resolution.resolved = true; + resolution.layer = layer; + resolution.value = resolvedValue; + return resolution; + } + + return resolution; +} + +UIResolvedStyle ResolveStyle(const UIStyleResolveContext& context) { + UIResolvedStyle resolvedStyle = {}; + const UIStylePropertyId knownProperties[] = { + UIStylePropertyId::BackgroundColor, + UIStylePropertyId::ForegroundColor, + UIStylePropertyId::BorderColor, + UIStylePropertyId::BorderWidth, + UIStylePropertyId::CornerRadius, + UIStylePropertyId::Padding, + UIStylePropertyId::Gap, + UIStylePropertyId::FontSize, + UIStylePropertyId::LineWidth + }; + + for (const UIStylePropertyId propertyId : knownProperties) { + const UIStylePropertyResolution propertyResolution = ResolveStyleProperty(propertyId, context); + if (propertyResolution.resolved) { + resolvedStyle.SetProperty(propertyId, propertyResolution); + } + } + + return resolvedStyle; +} + +const char* ToString(UIStyleLayer layer) { + switch (layer) { + case UIStyleLayer::Default: + return "Default"; + case UIStyleLayer::Type: + return "Type"; + case UIStyleLayer::Named: + return "Named"; + case UIStyleLayer::Local: + return "Local"; + default: + return "None"; + } +} + +} // namespace Style +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Style/StyleTypes.cpp b/engine/src/UI/Style/StyleTypes.cpp new file mode 100644 index 00000000..bbf6da0d --- /dev/null +++ b/engine/src/UI/Style/StyleTypes.cpp @@ -0,0 +1,245 @@ +#include + +#include + +namespace XCEngine { +namespace UI { +namespace Style { + +namespace { + +bool NearlyEqual(float left, float right) { + return std::abs(left - right) <= Math::EPSILON; +} + +bool ColorsEqual(const Math::Color& left, const Math::Color& right) { + return NearlyEqual(left.r, right.r) && + NearlyEqual(left.g, right.g) && + NearlyEqual(left.b, right.b) && + NearlyEqual(left.a, right.a); +} + +bool ThicknessEqual(const UIThickness& left, const UIThickness& right) { + return NearlyEqual(left.left, right.left) && + NearlyEqual(left.top, right.top) && + NearlyEqual(left.right, right.right) && + NearlyEqual(left.bottom, right.bottom); +} + +bool CornerRadiusEqual(const UICornerRadius& left, const UICornerRadius& right) { + return NearlyEqual(left.topLeft, right.topLeft) && + NearlyEqual(left.topRight, right.topRight) && + NearlyEqual(left.bottomRight, right.bottomRight) && + NearlyEqual(left.bottomLeft, right.bottomLeft); +} + +bool SizeEqual(const UISize& left, const UISize& right) { + return NearlyEqual(left.width, right.width) && + NearlyEqual(left.height, right.height); +} + +bool PointEqual(const UIPoint& left, const UIPoint& right) { + return NearlyEqual(left.x, right.x) && + NearlyEqual(left.y, right.y); +} + +} // namespace + +UIThickness UIThickness::Uniform(float value) { + return { value, value, value, value }; +} + +bool UIThickness::IsUniform() const { + return NearlyEqual(left, top) && + NearlyEqual(left, right) && + NearlyEqual(left, bottom); +} + +UICornerRadius UICornerRadius::Uniform(float value) { + return { value, value, value, value }; +} + +bool UICornerRadius::IsUniform() const { + return NearlyEqual(topLeft, topRight) && + NearlyEqual(topLeft, bottomRight) && + NearlyEqual(topLeft, bottomLeft); +} + +bool UITokenReference::IsValid() const { + return !name.empty(); +} + +UIStyleValue::UIStyleValue(float value) + : m_storage(value) { +} + +UIStyleValue::UIStyleValue(const Math::Color& value) + : m_storage(value) { +} + +UIStyleValue::UIStyleValue(const UIThickness& value) + : m_storage(value) { +} + +UIStyleValue::UIStyleValue(const UICornerRadius& value) + : m_storage(value) { +} + +UIStyleValue::UIStyleValue(const UISize& value) + : m_storage(value) { +} + +UIStyleValue::UIStyleValue(const UIPoint& value) + : m_storage(value) { +} + +UIStyleValue::UIStyleValue(const UITokenReference& tokenReference) + : m_storage(tokenReference) { +} + +UIStyleValue UIStyleValue::Token(const std::string& tokenName) { + return UIStyleValue(UITokenReference{ tokenName }); +} + +bool UIStyleValue::IsSet() const { + return !std::holds_alternative(m_storage); +} + +bool UIStyleValue::IsTokenReference() const { + return std::holds_alternative(m_storage); +} + +UIStyleValueType UIStyleValue::GetType() const { + if (std::holds_alternative(m_storage)) { + return UIStyleValueType::Float; + } + if (std::holds_alternative(m_storage)) { + return UIStyleValueType::Color; + } + if (std::holds_alternative(m_storage)) { + return UIStyleValueType::Thickness; + } + if (std::holds_alternative(m_storage)) { + return UIStyleValueType::CornerRadius; + } + if (std::holds_alternative(m_storage)) { + return UIStyleValueType::Size; + } + if (std::holds_alternative(m_storage)) { + return UIStyleValueType::Point; + } + if (std::holds_alternative(m_storage)) { + return UIStyleValueType::TokenReference; + } + return UIStyleValueType::None; +} + +const float* UIStyleValue::TryGetFloat() const { + return std::get_if(&m_storage); +} + +const Math::Color* UIStyleValue::TryGetColor() const { + return std::get_if(&m_storage); +} + +const UIThickness* UIStyleValue::TryGetThickness() const { + return std::get_if(&m_storage); +} + +const UICornerRadius* UIStyleValue::TryGetCornerRadius() const { + return std::get_if(&m_storage); +} + +const UISize* UIStyleValue::TryGetSize() const { + return std::get_if(&m_storage); +} + +const UIPoint* UIStyleValue::TryGetPoint() const { + return std::get_if(&m_storage); +} + +const UITokenReference* UIStyleValue::TryGetTokenReference() const { + return std::get_if(&m_storage); +} + +bool UIStyleValue::operator==(const UIStyleValue& other) const { + if (GetType() != other.GetType()) { + return false; + } + + if (const float* left = TryGetFloat()) { + return NearlyEqual(*left, *other.TryGetFloat()); + } + if (const Math::Color* left = TryGetColor()) { + return ColorsEqual(*left, *other.TryGetColor()); + } + if (const UIThickness* left = TryGetThickness()) { + return ThicknessEqual(*left, *other.TryGetThickness()); + } + if (const UICornerRadius* left = TryGetCornerRadius()) { + return CornerRadiusEqual(*left, *other.TryGetCornerRadius()); + } + if (const UISize* left = TryGetSize()) { + return SizeEqual(*left, *other.TryGetSize()); + } + if (const UIPoint* left = TryGetPoint()) { + return PointEqual(*left, *other.TryGetPoint()); + } + if (const UITokenReference* left = TryGetTokenReference()) { + return left->name == other.TryGetTokenReference()->name; + } + return true; +} + +bool UIStyleValue::operator!=(const UIStyleValue& other) const { + return !(*this == other); +} + +UIStyleValueType GetExpectedValueType(UIStylePropertyId propertyId) { + switch (propertyId) { + case UIStylePropertyId::BackgroundColor: + case UIStylePropertyId::ForegroundColor: + case UIStylePropertyId::BorderColor: + return UIStyleValueType::Color; + case UIStylePropertyId::BorderWidth: + case UIStylePropertyId::Gap: + case UIStylePropertyId::FontSize: + case UIStylePropertyId::LineWidth: + return UIStyleValueType::Float; + case UIStylePropertyId::CornerRadius: + return UIStyleValueType::CornerRadius; + case UIStylePropertyId::Padding: + return UIStyleValueType::Thickness; + default: + return UIStyleValueType::None; + } +} + +const char* ToString(UIStylePropertyId propertyId) { + switch (propertyId) { + case UIStylePropertyId::BackgroundColor: + return "BackgroundColor"; + case UIStylePropertyId::ForegroundColor: + return "ForegroundColor"; + case UIStylePropertyId::BorderColor: + return "BorderColor"; + case UIStylePropertyId::BorderWidth: + return "BorderWidth"; + case UIStylePropertyId::CornerRadius: + return "CornerRadius"; + case UIStylePropertyId::Padding: + return "Padding"; + case UIStylePropertyId::Gap: + return "Gap"; + case UIStylePropertyId::FontSize: + return "FontSize"; + case UIStylePropertyId::LineWidth: + return "LineWidth"; + default: + return "Unknown"; + } +} + +} // namespace Style +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Style/Theme.cpp b/engine/src/UI/Style/Theme.cpp new file mode 100644 index 00000000..a342e29d --- /dev/null +++ b/engine/src/UI/Style/Theme.cpp @@ -0,0 +1,145 @@ +#include + +#include + +namespace XCEngine { +namespace UI { +namespace Style { + +void UIThemeDefinition::SetToken(const std::string& tokenName, const UIStyleValue& value) { + tokens[tokenName] = value; +} + +const UIStyleValue* UIThemeDefinition::FindToken(const std::string& tokenName) const { + const auto it = tokens.find(tokenName); + return it != tokens.end() ? &it->second : nullptr; +} + +UITheme::UITheme(const UIThemeDefinition& definition) + : m_name(definition.name) + , m_tokens(definition.tokens) { +} + +void UITheme::SetName(const std::string& name) { + m_name = name; +} + +const std::string& UITheme::GetName() const { + return m_name; +} + +void UITheme::SetParent(const UITheme* parent) { + m_parent = parent; +} + +const UITheme* UITheme::GetParent() const { + return m_parent; +} + +void UITheme::SetToken(const std::string& tokenName, const UIStyleValue& value) { + m_tokens[tokenName] = value; +} + +bool UITheme::RemoveToken(const std::string& tokenName) { + return m_tokens.erase(tokenName) > 0u; +} + +const UIStyleValue* UITheme::FindToken(const std::string& tokenName) const { + const auto it = m_tokens.find(tokenName); + if (it != m_tokens.end()) { + return &it->second; + } + + return m_parent != nullptr ? m_parent->FindToken(tokenName) : nullptr; +} + +const std::map& UITheme::GetTokens() const { + return m_tokens; +} + +UITokenResolveResult UITheme::ResolveToken( + const std::string& tokenName, + UIStyleValueType expectedType) const { + std::vector visiting = {}; + return ResolveTokenRecursive(tokenName, expectedType, visiting); +} + +UITokenResolveResult UITheme::ResolveTokenRecursive( + const std::string& tokenName, + UIStyleValueType expectedType, + std::vector& visiting) const { + UITokenResolveResult result = {}; + if (tokenName.empty()) { + result.status = UITokenResolveStatus::MissingToken; + return result; + } + + if (std::find(visiting.begin(), visiting.end(), tokenName) != visiting.end()) { + result.status = UITokenResolveStatus::CycleDetected; + return result; + } + + const UIStyleValue* value = FindToken(tokenName); + if (value == nullptr || !value->IsSet()) { + result.status = UITokenResolveStatus::MissingToken; + return result; + } + + if (const UITokenReference* tokenReference = value->TryGetTokenReference()) { + visiting.push_back(tokenName); + result = ResolveTokenRecursive(tokenReference->name, expectedType, visiting); + visiting.pop_back(); + return result; + } + + if (expectedType != UIStyleValueType::None && value->GetType() != expectedType) { + result.status = UITokenResolveStatus::TypeMismatch; + return result; + } + + result.status = UITokenResolveStatus::Resolved; + result.value = *value; + return result; +} + +UITheme BuildTheme(const UIThemeDefinition& definition, const UITheme* parent) { + UITheme theme(definition); + theme.SetParent(parent); + return theme; +} + +UITheme BuildBuiltinTheme(UIBuiltinThemeKind themeKind) { + UIThemeDefinition definition = {}; + + if (themeKind == UIBuiltinThemeKind::NeutralLight) { + definition.name = "NeutralLight"; + definition.SetToken("color.surface", UIStyleValue(Math::Color(0.93f, 0.94f, 0.96f, 1.0f))); + definition.SetToken("color.surface.elevated", UIStyleValue(Math::Color(1.0f, 1.0f, 1.0f, 1.0f))); + definition.SetToken("color.text.primary", UIStyleValue(Math::Color(0.10f, 0.11f, 0.14f, 1.0f))); + definition.SetToken("color.accent", UIStyleValue(Math::Color(0.14f, 0.43f, 0.88f, 1.0f))); + definition.SetToken("space.compact", UIStyleValue(6.0f)); + definition.SetToken("space.regular", UIStyleValue(10.0f)); + definition.SetToken("padding.control", UIStyleValue(UIThickness::Uniform(10.0f))); + definition.SetToken("radius.control", UIStyleValue(UICornerRadius::Uniform(6.0f))); + definition.SetToken("font.body", UIStyleValue(14.0f)); + definition.SetToken("line.default", UIStyleValue(1.0f)); + return BuildTheme(definition); + } + + definition.name = "NeutralDark"; + definition.SetToken("color.surface", UIStyleValue(Math::Color(0.13f, 0.14f, 0.16f, 1.0f))); + definition.SetToken("color.surface.elevated", UIStyleValue(Math::Color(0.18f, 0.19f, 0.22f, 1.0f))); + definition.SetToken("color.text.primary", UIStyleValue(Math::Color(0.92f, 0.93f, 0.95f, 1.0f))); + definition.SetToken("color.accent", UIStyleValue(Math::Color(0.98f, 0.53f, 0.17f, 1.0f))); + definition.SetToken("space.compact", UIStyleValue(6.0f)); + definition.SetToken("space.regular", UIStyleValue(10.0f)); + definition.SetToken("padding.control", UIStyleValue(UIThickness::Uniform(10.0f))); + definition.SetToken("radius.control", UIStyleValue(UICornerRadius::Uniform(6.0f))); + definition.SetToken("font.body", UIStyleValue(14.0f)); + definition.SetToken("line.default", UIStyleValue(1.0f)); + return BuildTheme(definition); +} + +} // namespace Style +} // namespace UI +} // namespace XCEngine diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt index adc2a49b..632a452d 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -32,6 +32,7 @@ add_subdirectory(Math) # Core/UI Tests # ============================================================ add_subdirectory(UI) +add_subdirectory(UIStyle) # Exclude all static runtime libraries to avoid conflicts if(MSVC) diff --git a/tests/core/UIStyle/CMakeLists.txt b/tests/core/UIStyle/CMakeLists.txt new file mode 100644 index 00000000..62db5ff8 --- /dev/null +++ b/tests/core/UIStyle/CMakeLists.txt @@ -0,0 +1,30 @@ +# ============================================================ +# UI Style Tests +# ============================================================ + +set(UI_STYLE_TEST_SOURCES + test_style_system.cpp +) + +add_executable(core_ui_style_tests ${UI_STYLE_TEST_SOURCES}) + +if(MSVC) + set_target_properties(core_ui_style_tests PROPERTIES + LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib" + ) +endif() + +target_link_libraries(core_ui_style_tests + PRIVATE + XCEngine + GTest::gtest + GTest::gtest_main +) + +target_include_directories(core_ui_style_tests PRIVATE + ${CMAKE_SOURCE_DIR}/engine/include + ${CMAKE_SOURCE_DIR}/tests/Fixtures +) + +include(GoogleTest) +gtest_discover_tests(core_ui_style_tests) diff --git a/tests/core/UIStyle/test_style_system.cpp b/tests/core/UIStyle/test_style_system.cpp new file mode 100644 index 00000000..9f99b3e7 --- /dev/null +++ b/tests/core/UIStyle/test_style_system.cpp @@ -0,0 +1,166 @@ +#include + +#include + +namespace { + +using XCEngine::Math::Color; +using XCEngine::UI::Style::BuildBuiltinTheme; +using XCEngine::UI::Style::BuildTheme; +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); +} + +TEST(UIStyleSystem, 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(UIStyleSystem, 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(UIStyleSystem, 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(UIStyleSystem, 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(UIStyleSystem, 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); +} + +} // namespace