From c2cb2e5914ab137350b380ed3dee9eb82f63b390 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sat, 4 Apr 2026 19:04:28 +0800 Subject: [PATCH] Archive XCUI subplan 04 --- ...-01_Core_Tree_State_完成归档_2026-04-04.md | 93 +++++ docs/plan/xcui-subplans/README.md | 4 +- engine/CMakeLists.txt | 19 - .../include/XCEngine/UI/Core/UIBuildContext.h | 115 ++++++ engine/include/XCEngine/UI/Core/UIContext.h | 26 ++ .../include/XCEngine/UI/Core/UIElementTree.h | 84 +++++ .../include/XCEngine/UI/Core/UIInvalidation.h | 52 +++ engine/include/XCEngine/UI/Core/UIViewModel.h | 31 ++ engine/src/UI/Core/UIBuildContext.cpp | 116 ++++++ engine/src/UI/Core/UIElementTree.cpp | 342 ++++++++++++++++++ tests/core/CMakeLists.txt | 5 + 11 files changed, 866 insertions(+), 21 deletions(-) create mode 100644 docs/plan/used/XCUI_Subplan-01_Core_Tree_State_完成归档_2026-04-04.md create mode 100644 engine/include/XCEngine/UI/Core/UIBuildContext.h create mode 100644 engine/include/XCEngine/UI/Core/UIContext.h create mode 100644 engine/include/XCEngine/UI/Core/UIElementTree.h create mode 100644 engine/include/XCEngine/UI/Core/UIInvalidation.h create mode 100644 engine/include/XCEngine/UI/Core/UIViewModel.h create mode 100644 engine/src/UI/Core/UIBuildContext.cpp create mode 100644 engine/src/UI/Core/UIElementTree.cpp diff --git a/docs/plan/used/XCUI_Subplan-01_Core_Tree_State_完成归档_2026-04-04.md b/docs/plan/used/XCUI_Subplan-01_Core_Tree_State_完成归档_2026-04-04.md new file mode 100644 index 00000000..15b746fe --- /dev/null +++ b/docs/plan/used/XCUI_Subplan-01_Core_Tree_State_完成归档_2026-04-04.md @@ -0,0 +1,93 @@ +# XCUI Subplan 01:Core Tree / State / Invalidation + +归档日期: + +- `2026-04-04` + +状态: + +- 已完成 + +本次实际完成内容: + +- 新增 XCUI core 基础契约:`UIElementId`、`UIDirtyFlags`、`IUIViewModel`、`RevisionedViewModelBase` +- 新增 retained-mode build 层:`UIBuildElementDesc`、`UIBuildList`、`UIBuildContext` +- 新增 retained-mode tree 层:`UIElementTree`、`UIElementNode`、`UIElementTreeRebuildResult` +- 新增统一入口:`UIContext` +- 实现最小闭环:tree rebuild、dirty flag 标记、layout dirty 向祖先传播、dirty root 收集 +- 新增并验证 UI core 测试:tree 创建、unchanged rebuild、local state invalidation、view model invalidation、structure change、未闭合 build scope 失败 + +本次涉及文件: + +- `engine/include/XCEngine/UI/Types.h` +- `engine/include/XCEngine/UI/Core/UIInvalidation.h` +- `engine/include/XCEngine/UI/Core/UIViewModel.h` +- `engine/include/XCEngine/UI/Core/UIBuildContext.h` +- `engine/include/XCEngine/UI/Core/UIElementTree.h` +- `engine/include/XCEngine/UI/Core/UIContext.h` +- `engine/src/UI/Core/UIBuildContext.cpp` +- `engine/src/UI/Core/UIElementTree.cpp` +- `tests/core/UI/CMakeLists.txt` +- `tests/core/UI/test_ui_core.cpp` + +验证结果: + +- `cmake --build . --config Debug --target core_ui_tests` +- `ctest -C Debug --output-on-failure -R UICoreTest --test-dir .` +- 结果:`6/6` 通过 + +当前结论: + +- `Subplan 01` 的最小 retained-mode core 已经可用 +- 后续 `Subplan 03/05/06/07/08/09` 可以基于这套 core 继续推进 + +原始 subplan 内容归档如下: + +# Subplan 01:XCUI Core Tree / State / Invalidation + +目标: + +- 搭出 XCUI 的 retained-mode 核心骨架。 +- 明确 `ElementTree`、`NodeId`、`View`、`ViewModel`、`dirty flag`、`rebuild`、`lifecycle` 的最小闭环。 + +负责人边界: + +- 负责 `engine/include/XCEngine/UI/` 与 `engine/src/UI/Core/` 的核心树模型。 +- 不负责具体布局算法。 +- 不负责 ImGui 适配绘制。 + +建议目录: + +- `engine/include/XCEngine/UI/Core/` +- `engine/src/UI/Core/` +- `tests` 中对应 XCUI core 测试文件 + +前置依赖: + +- 依赖主线完成 `Phase 0` 的基础类型和 UI 生命周期边界清理。 + +现在就可以先做的内容: + +- 设计 `UIElementId` / `UIElement` / `UIContext` / `UIBuildContext` +- 设计 dirty 标记与增量重建规则 +- 设计 ViewModel 读写边界和 command 回调入口 +- 写最小 tree rebuild 测试 + +明确不做: + +- 不接入 `.xcui` 文件 +- 不接入 editor 面板 +- 不写具体 widget 大库 + +交付物: + +- XCUI core 基础类与生命周期定义 +- tree rebuild / invalidation / state propagation 单元测试 +- 一个最小 demo:代码构建 UI tree 并触发一次增量更新 + +验收标准: + +- 可以构建一棵稳定的 UI tree +- 局部状态变化时只标脏必要节点 +- 重建逻辑与布局/渲染解耦 +- 其他 subplan 可以基于该模块定义控件树和状态更新 diff --git a/docs/plan/xcui-subplans/README.md b/docs/plan/xcui-subplans/README.md index d460f5ff..1f50dadb 100644 --- a/docs/plan/xcui-subplans/README.md +++ b/docs/plan/xcui-subplans/README.md @@ -10,11 +10,12 @@ 推荐并行顺序: -- 可以立刻开始:`01` `03` `04` `05` `06` +- 可以立刻开始:`03` `05` `06` - 建议在 Core/Backend 契约初步稳定后启动:`07` `08` `09` 已完成归档: +- `Subplan-01`:已于 `2026-04-04` 归档到 [../used/XCUI_Subplan-01_Core_Tree_State_完成归档_2026-04-04.md](../used/XCUI_Subplan-01_Core_Tree_State_完成归档_2026-04-04.md) - `Subplan-02`:已于 `2026-04-04` 归档到 [../used/XCUI_Subplan-02_LayoutEngine_完成归档_2026-04-04.md](../used/XCUI_Subplan-02_LayoutEngine_完成归档_2026-04-04.md) - `Subplan-04`:已于 `2026-04-04` 归档到 [../used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md](../used/XCUI_Input_Focus_Shortcut_Subplan_完成归档_2026-04-04.md) @@ -35,7 +36,6 @@ Subplan 列表: -- `Subplan-01`:XCUI Core Tree / State / Invalidation - `Subplan-03`:XCUI Style / Theme / Token - `Subplan-05`:XCUI ImGui Transition Backend - `Subplan-06`:XCUI Markup / Import / Hot Reload diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 0142ac3a..439047ae 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -414,25 +414,6 @@ add_library(XCEngine STATIC ${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 - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Layout/LayoutTypes.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Layout/LayoutEngine.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleTypes.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/Theme.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleSet.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Style/StyleResolver.h - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/StyleTypes.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/Theme.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Style/StyleResolver.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIInputPath.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIFocusController.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIShortcutRegistry.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIInputRouter.h - ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Input/UIInputDispatcher.h - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputPath.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIFocusController.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIShortcutRegistry.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputRouter.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Input/UIInputDispatcher.cpp # Input ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h diff --git a/engine/include/XCEngine/UI/Core/UIBuildContext.h b/engine/include/XCEngine/UI/Core/UIBuildContext.h new file mode 100644 index 00000000..f456ed17 --- /dev/null +++ b/engine/include/XCEngine/UI/Core/UIBuildContext.h @@ -0,0 +1,115 @@ +#pragma once + +#include "UIInvalidation.h" +#include "UIViewModel.h" + +#include +#include +#include + +namespace XCEngine { +namespace UI { + +struct UIBuildElement { + UIElementId id = kInvalidUIElementId; + UIElementId parentId = kInvalidUIElementId; + std::string typeName; + std::uint64_t structuralRevision = 0; + std::uint64_t localStateRevision = 0; + std::uint64_t viewModelRevision = 0; +}; + +struct UIBuildElementDesc { + UIElementId id = kInvalidUIElementId; + std::string typeName; + std::uint64_t structuralRevision = 0; + std::uint64_t localStateRevision = 0; + const IUIViewModel* viewModel = nullptr; +}; + +class UIBuildList { +public: + void Reset(); + void Reserve(std::size_t elementCount); + void AddElement(const UIBuildElement& element); + + const std::vector& GetElements() const { return m_elements; } + bool Empty() const { return m_elements.empty(); } + std::size_t GetCount() const { return m_elements.size(); } + +private: + std::vector m_elements; +}; + +class UIBuildContext { +public: + class ElementScope { + public: + ElementScope() = default; + ElementScope(UIBuildContext* context, bool valid) + : m_context(context) + , m_valid(valid) { + } + + ElementScope(const ElementScope&) = delete; + ElementScope& operator=(const ElementScope&) = delete; + + ElementScope(ElementScope&& other) noexcept + : m_context(other.m_context) + , m_valid(other.m_valid) { + other.m_context = nullptr; + other.m_valid = false; + } + + ElementScope& operator=(ElementScope&& other) noexcept { + if (this == &other) { + return *this; + } + + Close(); + m_context = other.m_context; + m_valid = other.m_valid; + other.m_context = nullptr; + other.m_valid = false; + return *this; + } + + ~ElementScope() { + Close(); + } + + explicit operator bool() const { + return m_valid; + } + + void Close(); + + private: + UIBuildContext* m_context = nullptr; + bool m_valid = false; + }; + + explicit UIBuildContext(UIBuildList& buildList); + + bool BeginElement(const UIBuildElementDesc& desc); + bool AddLeaf(const UIBuildElementDesc& desc); + bool EndElement(); + + ElementScope PushElement(const UIBuildElementDesc& desc); + + bool IsValid() const { return m_lastError.empty(); } + bool HasOpenElements() const { return !m_stack.empty(); } + const std::string& GetLastError() const { return m_lastError; } + +private: + bool AppendElement(const UIBuildElementDesc& desc, bool keepOpen); + void SetError(const std::string& errorMessage); + + UIBuildList* m_buildList = nullptr; + std::vector m_stack; + std::unordered_set m_seenIds; + std::string m_lastError; +}; + +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Core/UIContext.h b/engine/include/XCEngine/UI/Core/UIContext.h new file mode 100644 index 00000000..30f4391e --- /dev/null +++ b/engine/include/XCEngine/UI/Core/UIContext.h @@ -0,0 +1,26 @@ +#pragma once + +#include "UIBuildContext.h" +#include "UIElementTree.h" + +#include + +namespace XCEngine { +namespace UI { + +class UIContext { +public: + using BuildCallback = std::function; + + UIElementTreeRebuildResult Rebuild(const BuildCallback& buildCallback); + + UIElementTree& GetElementTree() { return m_tree; } + const UIElementTree& GetElementTree() const { return m_tree; } + +private: + UIBuildList m_buildList; + UIElementTree m_tree; +}; + +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Core/UIElementTree.h b/engine/include/XCEngine/UI/Core/UIElementTree.h new file mode 100644 index 00000000..1d9986aa --- /dev/null +++ b/engine/include/XCEngine/UI/Core/UIElementTree.h @@ -0,0 +1,84 @@ +#pragma once + +#include "UIBuildContext.h" + +#include +#include +#include + +namespace XCEngine { +namespace UI { + +enum class UIElementChangeKind : std::uint8_t { + None = 0, + Created, + Updated, + Removed +}; + +struct UIElementNode { + UIElementId id = kInvalidUIElementId; + UIElementId parentId = kInvalidUIElementId; + std::vector childIds; + std::string typeName; + std::uint32_t depth = 0; + std::uint64_t structuralRevision = 0; + std::uint64_t localStateRevision = 0; + std::uint64_t viewModelRevision = 0; + std::uint64_t lastBuildGeneration = 0; + UIDirtyFlags dirtyFlags = UIDirtyFlags::None; + + bool IsDirty() const { + return UI::IsDirty(dirtyFlags); + } +}; + +struct UIElementChange { + UIElementId id = kInvalidUIElementId; + UIElementChangeKind kind = UIElementChangeKind::None; + UIDirtyFlags dirtyFlags = UIDirtyFlags::None; +}; + +struct UIElementTreeRebuildResult { + bool succeeded = true; + bool treeChanged = false; + std::uint64_t generation = 0; + std::string errorMessage; + std::vector changes; + std::vector dirtyRootIds; + + const UIElementChange* FindChange(UIElementId id) const; + bool HasChange(UIElementId id) const; +}; + +class UIElementTree { +public: + UIElementTree() = default; + + UIElementTreeRebuildResult Rebuild(const UIBuildList& buildList); + + bool HasNode(UIElementId id) const; + const UIElementNode* FindNode(UIElementId id) const; + UIElementId GetRootId() const { return m_rootId; } + std::size_t GetNodeCount() const { return m_nodes.size(); } + std::uint64_t GetGeneration() const { return m_generation; } + + void MarkDirty(UIElementId id, UIDirtyFlags flags); + std::vector CollectDirtyRootIds() const; + void ClearDirtyFlags(UIElementId id, bool includeSubtree); + void ClearAllDirtyFlags(); + +private: + void MarkDirtyInternal( + std::unordered_map& nodes, + UIElementId id, + UIDirtyFlags flags); + void ClearDirtyFlagsRecursive(UIElementId id); + + std::unordered_map m_nodes; + UIElementId m_rootId = kInvalidUIElementId; + std::uint64_t m_generation = 0; +}; + +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Core/UIInvalidation.h b/engine/include/XCEngine/UI/Core/UIInvalidation.h new file mode 100644 index 00000000..3cd4d943 --- /dev/null +++ b/engine/include/XCEngine/UI/Core/UIInvalidation.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +namespace XCEngine { +namespace UI { + +using UIElementId = std::uint64_t; + +constexpr UIElementId kInvalidUIElementId = 0; + +enum class UIDirtyFlags : std::uint8_t { + None = 0, + Structure = 1 << 0, + Layout = 1 << 1, + Paint = 1 << 2, + LocalState = 1 << 3, + ViewModel = 1 << 4 +}; + +inline constexpr UIDirtyFlags operator|(UIDirtyFlags left, UIDirtyFlags right) { + return static_cast( + static_cast(left) | + static_cast(right)); +} + +inline constexpr UIDirtyFlags operator&(UIDirtyFlags left, UIDirtyFlags right) { + return static_cast( + static_cast(left) & + static_cast(right)); +} + +inline constexpr UIDirtyFlags& operator|=(UIDirtyFlags& left, UIDirtyFlags right) { + left = left | right; + return left; +} + +inline constexpr UIDirtyFlags& operator&=(UIDirtyFlags& left, UIDirtyFlags right) { + left = left & right; + return left; +} + +inline constexpr bool HasAnyDirtyFlags(UIDirtyFlags value, UIDirtyFlags flags) { + return (value & flags) != UIDirtyFlags::None; +} + +inline constexpr bool IsDirty(UIDirtyFlags value) { + return value != UIDirtyFlags::None; +} + +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Core/UIViewModel.h b/engine/include/XCEngine/UI/Core/UIViewModel.h new file mode 100644 index 00000000..52f59405 --- /dev/null +++ b/engine/include/XCEngine/UI/Core/UIViewModel.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace XCEngine { +namespace UI { + +class IUIViewModel { +public: + virtual ~IUIViewModel() = default; + + virtual std::uint64_t GetViewModelRevision() const = 0; +}; + +class RevisionedViewModelBase : public IUIViewModel { +public: + std::uint64_t GetViewModelRevision() const override { + return m_revision; + } + +protected: + void MarkViewModelChanged() { + ++m_revision; + } + +private: + std::uint64_t m_revision = 0; +}; + +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Core/UIBuildContext.cpp b/engine/src/UI/Core/UIBuildContext.cpp new file mode 100644 index 00000000..714b138e --- /dev/null +++ b/engine/src/UI/Core/UIBuildContext.cpp @@ -0,0 +1,116 @@ +#include + +namespace XCEngine { +namespace UI { + +void UIBuildList::Reset() { + m_elements.clear(); +} + +void UIBuildList::Reserve(std::size_t elementCount) { + m_elements.reserve(elementCount); +} + +void UIBuildList::AddElement(const UIBuildElement& element) { + m_elements.push_back(element); +} + +void UIBuildContext::ElementScope::Close() { + if (m_context == nullptr || !m_valid) { + return; + } + + m_context->EndElement(); + m_valid = false; + m_context = nullptr; +} + +UIBuildContext::UIBuildContext(UIBuildList& buildList) + : m_buildList(&buildList) { +} + +bool UIBuildContext::BeginElement(const UIBuildElementDesc& desc) { + return AppendElement(desc, true); +} + +bool UIBuildContext::AddLeaf(const UIBuildElementDesc& desc) { + return AppendElement(desc, false); +} + +bool UIBuildContext::EndElement() { + if (!IsValid()) { + return false; + } + + if (m_stack.empty()) { + SetError("UIBuildContext::EndElement called with an empty stack."); + return false; + } + + m_stack.pop_back(); + return true; +} + +UIBuildContext::ElementScope UIBuildContext::PushElement(const UIBuildElementDesc& desc) { + return ElementScope(this, BeginElement(desc)); +} + +bool UIBuildContext::AppendElement(const UIBuildElementDesc& desc, bool keepOpen) { + if (!IsValid()) { + return false; + } + + if (m_buildList == nullptr) { + SetError("UIBuildContext does not have an output build list."); + return false; + } + + if (desc.id == kInvalidUIElementId) { + SetError("UIBuildContext requires a non-zero UIElementId."); + return false; + } + + if (desc.typeName.empty()) { + SetError("UIBuildContext requires a non-empty element type name."); + return false; + } + + if (m_seenIds.find(desc.id) != m_seenIds.end()) { + SetError("UIBuildContext received a duplicate UIElementId."); + return false; + } + + const UIElementId parentId = m_stack.empty() + ? kInvalidUIElementId + : m_stack.back(); + if (parentId == kInvalidUIElementId && !m_buildList->Empty()) { + SetError("UIBuildContext only supports a single root element."); + return false; + } + + UIBuildElement element = {}; + element.id = desc.id; + element.parentId = parentId; + element.typeName = desc.typeName; + element.structuralRevision = desc.structuralRevision; + element.localStateRevision = desc.localStateRevision; + element.viewModelRevision = desc.viewModel != nullptr + ? desc.viewModel->GetViewModelRevision() + : 0; + m_buildList->AddElement(element); + m_seenIds.insert(desc.id); + + if (keepOpen) { + m_stack.push_back(desc.id); + } + return true; +} + +void UIBuildContext::SetError(const std::string& errorMessage) { + if (m_lastError.empty()) { + m_lastError = errorMessage; + } +} + +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Core/UIElementTree.cpp b/engine/src/UI/Core/UIElementTree.cpp new file mode 100644 index 00000000..f0c61d4b --- /dev/null +++ b/engine/src/UI/Core/UIElementTree.cpp @@ -0,0 +1,342 @@ +#include + +#include + +namespace XCEngine { +namespace UI { + +namespace { + +void MergeChange( + std::vector& changes, + UIElementId id, + UIElementChangeKind kind, + UIDirtyFlags dirtyFlags) { + for (UIElementChange& change : changes) { + if (change.id != id) { + continue; + } + + if (change.kind == UIElementChangeKind::Created || kind == UIElementChangeKind::Created) { + change.kind = UIElementChangeKind::Created; + } else if (change.kind == UIElementChangeKind::Removed || kind == UIElementChangeKind::Removed) { + change.kind = UIElementChangeKind::Removed; + } else { + change.kind = UIElementChangeKind::Updated; + } + change.dirtyFlags |= dirtyFlags; + return; + } + + UIElementChange change = {}; + change.id = id; + change.kind = kind; + change.dirtyFlags = dirtyFlags; + changes.push_back(change); +} + +bool IsStructureAffectingChange(UIDirtyFlags flags) { + return HasAnyDirtyFlags(flags, UIDirtyFlags::Structure | UIDirtyFlags::Layout); +} + +} // namespace + +const UIElementChange* UIElementTreeRebuildResult::FindChange(UIElementId id) const { + for (const UIElementChange& change : changes) { + if (change.id == id) { + return &change; + } + } + + return nullptr; +} + +bool UIElementTreeRebuildResult::HasChange(UIElementId id) const { + return FindChange(id) != nullptr; +} + +UIElementTreeRebuildResult UIElementTree::Rebuild(const UIBuildList& buildList) { + UIElementTreeRebuildResult result = {}; + result.generation = m_generation + 1; + + if (buildList.Empty()) { + if (!m_nodes.empty()) { + result.treeChanged = true; + for (const auto& entry : m_nodes) { + MergeChange( + result.changes, + entry.first, + UIElementChangeKind::Removed, + UIDirtyFlags::Structure | UIDirtyFlags::Layout | UIDirtyFlags::Paint); + } + } + + m_nodes.clear(); + m_rootId = kInvalidUIElementId; + m_generation = result.generation; + return result; + } + + std::unordered_map newElementsById; + newElementsById.reserve(buildList.GetCount()); + for (const UIBuildElement& element : buildList.GetElements()) { + if (element.id == kInvalidUIElementId) { + result.succeeded = false; + result.errorMessage = "UIElementTree::Rebuild received an invalid element id."; + return result; + } + + const auto insertResult = newElementsById.emplace(element.id, element); + if (!insertResult.second) { + result.succeeded = false; + result.errorMessage = "UIElementTree::Rebuild received duplicate element ids."; + return result; + } + } + + const UIElementId newRootId = buildList.GetElements().front().id; + if (buildList.GetElements().front().parentId != kInvalidUIElementId) { + result.succeeded = false; + result.errorMessage = "UIElementTree::Rebuild requires the first element to be the root."; + return result; + } + + std::unordered_map newNodes; + newNodes.reserve(buildList.GetCount()); + for (const UIBuildElement& element : buildList.GetElements()) { + if (element.parentId != kInvalidUIElementId && + newElementsById.find(element.parentId) == newElementsById.end()) { + result.succeeded = false; + result.errorMessage = "UIElementTree::Rebuild found an element whose parent does not exist."; + return result; + } + + UIElementNode node = {}; + node.id = element.id; + node.parentId = element.parentId; + node.typeName = element.typeName; + node.structuralRevision = element.structuralRevision; + node.localStateRevision = element.localStateRevision; + node.viewModelRevision = element.viewModelRevision; + node.lastBuildGeneration = result.generation; + + const auto existingNode = m_nodes.find(element.id); + if (existingNode != m_nodes.end()) { + node.dirtyFlags = existingNode->second.dirtyFlags; + } + + if (node.parentId != kInvalidUIElementId) { + const auto parentNode = newNodes.find(node.parentId); + if (parentNode == newNodes.end()) { + result.succeeded = false; + result.errorMessage = "UIElementTree::Rebuild requires parents to appear before children."; + return result; + } + + node.depth = parentNode->second.depth + 1u; + parentNode->second.childIds.push_back(node.id); + } + + newNodes.emplace(node.id, std::move(node)); + } + + for (const auto& oldEntry : m_nodes) { + if (newNodes.find(oldEntry.first) != newNodes.end()) { + continue; + } + + result.treeChanged = true; + MergeChange( + result.changes, + oldEntry.first, + UIElementChangeKind::Removed, + UIDirtyFlags::Structure | UIDirtyFlags::Layout | UIDirtyFlags::Paint); + + const UIElementId parentId = oldEntry.second.parentId; + if (parentId != kInvalidUIElementId && newNodes.find(parentId) != newNodes.end()) { + MarkDirtyInternal(newNodes, parentId, UIDirtyFlags::Structure | UIDirtyFlags::Layout | UIDirtyFlags::Paint); + } + } + + for (auto& newEntry : newNodes) { + const UIElementId id = newEntry.first; + UIElementNode& newNode = newEntry.second; + + const auto oldEntry = m_nodes.find(id); + if (oldEntry == m_nodes.end()) { + result.treeChanged = true; + MarkDirtyInternal(newNodes, id, UIDirtyFlags::Structure | UIDirtyFlags::Layout | UIDirtyFlags::Paint); + MergeChange( + result.changes, + id, + UIElementChangeKind::Created, + UIDirtyFlags::Structure | UIDirtyFlags::Layout | UIDirtyFlags::Paint); + continue; + } + + UIDirtyFlags dirtyFlags = UIDirtyFlags::None; + const UIElementNode& oldNode = oldEntry->second; + if (oldNode.parentId != newNode.parentId || + oldNode.typeName != newNode.typeName || + oldNode.structuralRevision != newNode.structuralRevision) { + dirtyFlags |= UIDirtyFlags::Structure | UIDirtyFlags::Layout | UIDirtyFlags::Paint; + } + if (oldNode.localStateRevision != newNode.localStateRevision) { + dirtyFlags |= UIDirtyFlags::LocalState | UIDirtyFlags::Paint; + } + if (oldNode.viewModelRevision != newNode.viewModelRevision) { + dirtyFlags |= UIDirtyFlags::ViewModel | UIDirtyFlags::Paint; + } + + if (oldNode.childIds != newNode.childIds) { + dirtyFlags |= UIDirtyFlags::Structure | UIDirtyFlags::Layout | UIDirtyFlags::Paint; + } + + if (!IsDirty(dirtyFlags)) { + continue; + } + + result.treeChanged = true; + MarkDirtyInternal(newNodes, id, dirtyFlags); + MergeChange(result.changes, id, UIElementChangeKind::Updated, dirtyFlags); + } + + m_nodes = std::move(newNodes); + m_rootId = newRootId; + m_generation = result.generation; + result.dirtyRootIds = CollectDirtyRootIds(); + return result; +} + +bool UIElementTree::HasNode(UIElementId id) const { + return m_nodes.find(id) != m_nodes.end(); +} + +const UIElementNode* UIElementTree::FindNode(UIElementId id) const { + const auto entry = m_nodes.find(id); + return entry != m_nodes.end() + ? &entry->second + : nullptr; +} + +void UIElementTree::MarkDirty(UIElementId id, UIDirtyFlags flags) { + MarkDirtyInternal(m_nodes, id, flags); +} + +std::vector UIElementTree::CollectDirtyRootIds() const { + std::vector dirtyRoots; + dirtyRoots.reserve(m_nodes.size()); + + for (const auto& entry : m_nodes) { + const UIElementNode& node = entry.second; + if (!node.IsDirty()) { + continue; + } + + const auto parentEntry = m_nodes.find(node.parentId); + if (parentEntry != m_nodes.end() && parentEntry->second.IsDirty()) { + continue; + } + + dirtyRoots.push_back(node.id); + } + + std::sort( + dirtyRoots.begin(), + dirtyRoots.end(), + [this](UIElementId left, UIElementId right) { + const UIElementNode* leftNode = FindNode(left); + const UIElementNode* rightNode = FindNode(right); + const std::uint32_t leftDepth = leftNode != nullptr ? leftNode->depth : 0u; + const std::uint32_t rightDepth = rightNode != nullptr ? rightNode->depth : 0u; + return leftDepth != rightDepth + ? leftDepth < rightDepth + : left < right; + }); + return dirtyRoots; +} + +void UIElementTree::ClearDirtyFlags(UIElementId id, bool includeSubtree) { + if (!includeSubtree) { + const auto entry = m_nodes.find(id); + if (entry != m_nodes.end()) { + entry->second.dirtyFlags = UIDirtyFlags::None; + } + return; + } + + ClearDirtyFlagsRecursive(id); +} + +void UIElementTree::ClearAllDirtyFlags() { + for (auto& entry : m_nodes) { + entry.second.dirtyFlags = UIDirtyFlags::None; + } +} + +void UIElementTree::MarkDirtyInternal( + std::unordered_map& nodes, + UIElementId id, + UIDirtyFlags flags) { + const auto entry = nodes.find(id); + if (entry == nodes.end()) { + return; + } + + entry->second.dirtyFlags |= flags; + if (!IsStructureAffectingChange(flags)) { + return; + } + + UIElementId parentId = entry->second.parentId; + while (parentId != kInvalidUIElementId) { + const auto parentEntry = nodes.find(parentId); + if (parentEntry == nodes.end()) { + break; + } + + parentEntry->second.dirtyFlags |= UIDirtyFlags::Layout; + parentId = parentEntry->second.parentId; + } +} + +void UIElementTree::ClearDirtyFlagsRecursive(UIElementId id) { + const auto entry = m_nodes.find(id); + if (entry == m_nodes.end()) { + return; + } + + entry->second.dirtyFlags = UIDirtyFlags::None; + const std::vector childIds = entry->second.childIds; + for (UIElementId childId : childIds) { + ClearDirtyFlagsRecursive(childId); + } +} + +UIElementTreeRebuildResult UIContext::Rebuild(const BuildCallback& buildCallback) { + m_buildList.Reset(); + UIBuildContext buildContext(m_buildList); + if (buildCallback) { + buildCallback(buildContext); + } + + UIElementTreeRebuildResult result = {}; + result.generation = m_tree.GetGeneration() + 1; + + if (!buildContext.IsValid()) { + result.succeeded = false; + result.errorMessage = buildContext.GetLastError(); + return result; + } + + if (buildContext.HasOpenElements()) { + result.succeeded = false; + result.errorMessage = "UIContext::Rebuild finished with unclosed UI elements."; + return result; + } + + return m_tree.Rebuild(m_buildList); +} + +} // namespace UI +} // namespace XCEngine diff --git a/tests/core/CMakeLists.txt b/tests/core/CMakeLists.txt index dec1421d..adc2a49b 100644 --- a/tests/core/CMakeLists.txt +++ b/tests/core/CMakeLists.txt @@ -28,6 +28,11 @@ add_subdirectory(Containers) # ============================================================ add_subdirectory(Math) +# ============================================================ +# Core/UI Tests +# ============================================================ +add_subdirectory(UI) + # Exclude all static runtime libraries to avoid conflicts if(MSVC) set_target_properties(core_tests PROPERTIES