Archive XCUI subplan 04
This commit is contained in:
@@ -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 可以基于该模块定义控件树和状态更新
|
||||||
@@ -10,11 +10,12 @@
|
|||||||
|
|
||||||
推荐并行顺序:
|
推荐并行顺序:
|
||||||
|
|
||||||
- 可以立刻开始:`01` `03` `04` `05` `06`
|
- 可以立刻开始:`03` `05` `06`
|
||||||
- 建议在 Core/Backend 契约初步稳定后启动:`07` `08` `09`
|
- 建议在 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-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)
|
- `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 列表:
|
||||||
|
|
||||||
- `Subplan-01`:XCUI Core Tree / State / Invalidation
|
|
||||||
- `Subplan-03`:XCUI Style / Theme / Token
|
- `Subplan-03`:XCUI Style / Theme / Token
|
||||||
- `Subplan-05`:XCUI ImGui Transition Backend
|
- `Subplan-05`:XCUI ImGui Transition Backend
|
||||||
- `Subplan-06`:XCUI Markup / Import / Hot Reload
|
- `Subplan-06`:XCUI Markup / Import / Hot Reload
|
||||||
|
|||||||
@@ -414,25 +414,6 @@ add_library(XCEngine STATIC
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Core/UIContext.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/UIBuildContext.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Core/UIElementTree.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
|
# Input
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h
|
${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/Input/InputTypes.h
|
||||||
|
|||||||
115
engine/include/XCEngine/UI/Core/UIBuildContext.h
Normal file
115
engine/include/XCEngine/UI/Core/UIBuildContext.h
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "UIInvalidation.h"
|
||||||
|
#include "UIViewModel.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<UIBuildElement>& GetElements() const { return m_elements; }
|
||||||
|
bool Empty() const { return m_elements.empty(); }
|
||||||
|
std::size_t GetCount() const { return m_elements.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<UIBuildElement> 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<UIElementId> m_stack;
|
||||||
|
std::unordered_set<UIElementId> m_seenIds;
|
||||||
|
std::string m_lastError;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
26
engine/include/XCEngine/UI/Core/UIContext.h
Normal file
26
engine/include/XCEngine/UI/Core/UIContext.h
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "UIBuildContext.h"
|
||||||
|
#include "UIElementTree.h"
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
class UIContext {
|
||||||
|
public:
|
||||||
|
using BuildCallback = std::function<void(UIBuildContext&)>;
|
||||||
|
|
||||||
|
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
|
||||||
84
engine/include/XCEngine/UI/Core/UIElementTree.h
Normal file
84
engine/include/XCEngine/UI/Core/UIElementTree.h
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "UIBuildContext.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<UIElementId> 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<UIElementChange> changes;
|
||||||
|
std::vector<UIElementId> 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<UIElementId> CollectDirtyRootIds() const;
|
||||||
|
void ClearDirtyFlags(UIElementId id, bool includeSubtree);
|
||||||
|
void ClearAllDirtyFlags();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void MarkDirtyInternal(
|
||||||
|
std::unordered_map<UIElementId, UIElementNode>& nodes,
|
||||||
|
UIElementId id,
|
||||||
|
UIDirtyFlags flags);
|
||||||
|
void ClearDirtyFlagsRecursive(UIElementId id);
|
||||||
|
|
||||||
|
std::unordered_map<UIElementId, UIElementNode> m_nodes;
|
||||||
|
UIElementId m_rootId = kInvalidUIElementId;
|
||||||
|
std::uint64_t m_generation = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace UI
|
||||||
|
} // namespace XCEngine
|
||||||
52
engine/include/XCEngine/UI/Core/UIInvalidation.h
Normal file
52
engine/include/XCEngine/UI/Core/UIInvalidation.h
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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<UIDirtyFlags>(
|
||||||
|
static_cast<std::uint8_t>(left) |
|
||||||
|
static_cast<std::uint8_t>(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline constexpr UIDirtyFlags operator&(UIDirtyFlags left, UIDirtyFlags right) {
|
||||||
|
return static_cast<UIDirtyFlags>(
|
||||||
|
static_cast<std::uint8_t>(left) &
|
||||||
|
static_cast<std::uint8_t>(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
|
||||||
31
engine/include/XCEngine/UI/Core/UIViewModel.h
Normal file
31
engine/include/XCEngine/UI/Core/UIViewModel.h
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
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
|
||||||
116
engine/src/UI/Core/UIBuildContext.cpp
Normal file
116
engine/src/UI/Core/UIBuildContext.cpp
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
#include <XCEngine/UI/Core/UIBuildContext.h>
|
||||||
|
|
||||||
|
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
|
||||||
342
engine/src/UI/Core/UIElementTree.cpp
Normal file
342
engine/src/UI/Core/UIElementTree.cpp
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
#include <XCEngine/UI/Core/UIContext.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace XCEngine {
|
||||||
|
namespace UI {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void MergeChange(
|
||||||
|
std::vector<UIElementChange>& 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<UIElementId, UIBuildElement> 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<UIElementId, UIElementNode> 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<UIElementId> UIElementTree::CollectDirtyRootIds() const {
|
||||||
|
std::vector<UIElementId> 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<UIElementId, UIElementNode>& 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<UIElementId> 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
|
||||||
@@ -28,6 +28,11 @@ add_subdirectory(Containers)
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
add_subdirectory(Math)
|
add_subdirectory(Math)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Core/UI Tests
|
||||||
|
# ============================================================
|
||||||
|
add_subdirectory(UI)
|
||||||
|
|
||||||
# Exclude all static runtime libraries to avoid conflicts
|
# Exclude all static runtime libraries to avoid conflicts
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
set_target_properties(core_tests PROPERTIES
|
set_target_properties(core_tests PROPERTIES
|
||||||
|
|||||||
Reference in New Issue
Block a user