diff --git a/docs/plan/XCUI完整架构设计与执行计划.md b/docs/plan/XCUI完整架构设计与执行计划.md
new file mode 100644
index 00000000..87b21747
--- /dev/null
+++ b/docs/plan/XCUI完整架构设计与执行计划.md
@@ -0,0 +1,1571 @@
+# XCUI完整架构设计与执行计划
+
+## 1. 文档定位
+
+本文档定义 `XCUI` 的完整目标、分层架构、核心数据模型、运行机制、编辑器集成方式、迁移策略与执行计划。
+
+`XCUI` 的定位不是“再做一个游戏内 UI 模块”,而是:
+
+- XCEngine 的统一 UI 平台
+- Editor 与 Runtime 共用的一套 UI Core
+- 面向 AI Coding Agent 友好的 UI 开发体系
+- 未来替代当前 editor 中 ImGui 主路径的正式方案
+
+本文档默认服务于两个目标:
+
+- 给后续实现提供明确蓝图
+- 给 AI / 人类开发者提供稳定的架构边界
+
+---
+
+## 2. 背景与问题定义
+
+当前 XCEngine 的 editor 已经形成了较清晰的主链:
+
+- `RHI -> Rendering -> Editor Viewport -> AssetDatabase/Library -> Mono Scripting`
+
+但 UI 层目前仍以 ImGui 即时模式为主,存在以下问题:
+
+- 过程式 UI 代码体量大,面板文件容易膨胀
+- 状态、布局、事件、业务逻辑容易缠绕在一起
+- AI 不擅长稳定修改大段 imperative native UI 代码
+- 现有 UI 逻辑难以沉淀为资源化、组件化、可热重载的体系
+- editor UI 和未来 runtime UI 之间没有统一技术底座
+
+### 2.1 当前问题不是“有没有 UI”,而是“UI 的组织方式不适合 AI”
+
+当前 editor 已经有大量真实能力:
+
+- Hierarchy
+- Project
+- Inspector
+- Console
+- Scene/Game Viewport
+- Play Mode / Scripting / Asset 工作流
+
+因此,XCUI 的目标不是重做 editor 后端能力,而是重做 UI 前端组织方式。
+
+### 2.2 为什么不能直接照抄 Unity UI
+
+不采用 Unity 风格 UI 的主要原因:
+
+- `RectTransform / Anchor / Pivot / Canvas` 模型复杂度高
+- 组件化表达不够直接
+- inspector/custom editor 体系过重
+- 对 AI 来说,状态流和布局意图不够清晰
+- editor UI 与 runtime UI 分裂严重
+
+### 2.3 为什么也不直接采用浏览器/WebView路线
+
+本文档明确不采用“真正 HTML/CSS/JS + 浏览器内核”作为主路线,原因如下:
+
+- 这会把问题从 UI 架构转移成浏览器宿主工程
+- 会引入大量与引擎无关的复杂性
+- 不利于 editor/runtime 共享同一套 native UI Core
+- 会让 viewport、输入、宿主生命周期复杂化
+
+XCUI 借鉴前端范式,但不引入浏览器运行时。
+
+---
+
+## 3. XCUI的总体目标
+
+### 3.1 核心目标
+
+XCUI 必须同时满足以下目标:
+
+1. AI 能稳定理解、生成、修改 UI
+2. Editor 与 Runtime 共用统一 UI Core
+3. UI 可资源化、可热重载、可调试
+4. 复杂度显著低于 Unity UI
+5. 能逐步替代 editor 当前的 ImGui 主路径
+6. 能承载 editor 级别的面板、布局、属性表单、树控件和 viewport 壳层
+
+### 3.2 非目标
+
+XCUI 明确不做以下事情:
+
+- 不做浏览器
+- 不做 DOM
+- 不做完整 CSS cascade
+- 不复刻 Unity UGUI / UI Toolkit
+- 不把普通 UI 节点做成 Scene GameObject
+- 不在 V1 做完整 world-space UI 体系
+
+### 3.3 成功标准
+
+XCUI 成功的标志不是“能画几个按钮”,而是:
+
+- editor 核心公共边界中不再暴露 ImGui 类型
+- Inspector / Project / Console / Hierarchy 至少 4 个核心面板迁移到 XCUI
+- Scene/Game 使用 `ViewportSlot` 承载视口纹理和输入边界
+- `.xcui/.xctheme/.xcschema` 支持热重载
+- runtime HUD/menu 可以基于同一套 UI Core 构建
+- editor 最终可不依赖 ImGui 运行主 UI
+
+---
+
+## 4. 总体架构
+
+### 4.1 分层结构
+
+XCUI 采用如下四层架构:
+
+```text
+Editor / Runtime Application
+ |
+ v
+XCUI Views / ViewModels / Commands
+ |
+ v
+XCUI Core
+(State / Binding / Layout / Style / Input / Render / Widgets)
+ |
+ v
+Backend
+(ImGui Adapter -> 过渡期)
+(Native RHI Renderer -> 最终目标)
+```
+
+### 4.2 在仓库中的推荐目录
+
+```text
+engine/
+ include/XCEngine/UI/
+ Core/
+ State/
+ Binding/
+ Layout/
+ Style/
+ Input/
+ Render/
+ Text/
+ Widgets/
+ Markup/
+ Schema/
+ Runtime/
+ DevTools/
+ src/UI/
+
+editor/
+ src/XCUIHost/
+ src/XCUIViewModels/
+ src/XCUIAdapters/
+ src/XCUICommands/
+ ui/views/
+ ui/themes/
+ ui/schemas/
+
+project/
+ Assets/UI/
+ Assets/Themes/
+ Assets/Schemas/
+
+tests/
+ UI/
+```
+### 4.2.1 当前执行覆盖规则(2026-04-06)
+
+以下规则在当前阶段覆盖本节中所有与目录落点有关的模糊表述:
+
+- `editor/` 当前视为 ImGui 版本冻结区;在 XCUI editor shell 成熟前,不直接在该目录中推进替换开发。
+- 新的 `Editor` 层实现当前先落在 `new_editor/`,用于 editor shell、editor-only widget 与工作区壳层构建。
+- `tests/UI` 继续只承担 XCUI 的 `unit / integration` 验证职责,不承载正式 editor 实现。
+- `engine/UI` 当前继续只放 `Core / Runtime / shared` 部分,不再继续沉积 editor-only 代码。
+- 当 `new_editor/` 中的 XCUI editor shell 达到替换条件后,再计划性回收进正式 `editor/`。
+
+#### 当前过渡期目录(自 2026-04-06 起执行)
+```text
+engine/
+ include/XCEngine/UI/
+ src/UI/
+ # 只承载 Core / Runtime / shared UI 基础层
+
+new_editor/
+ src/
+ ui/
+ captures/
+ # 当前新的 Editor 层与 editor shell 先在这里构建
+
+editor/
+ # 当前 ImGui 版本冻结,不作为本阶段 XCUI 主实现目录
+
+tests/
+ UI/
+ # XCUI 验证体系入口,不承载正式 editor 实现
+```
+
+### 4.3 模块划分
+
+| 模块 | 职责 |
+|---|---|
+| `UI/Core` | UI 树、节点生命周期、失效传播、实例管理 |
+| `UI/State` | 响应式状态、派生状态、store |
+| `UI/Binding` | 数据绑定、命令绑定、viewmodel 适配 |
+| `UI/Layout` | 测量、布局、容器规则 |
+| `UI/Style` | 样式、主题 token、状态样式 |
+| `UI/Input` | 指针、键盘、焦点、拖拽、快捷键 |
+| `UI/Render` | draw list、渲染命令、后端抽象 |
+| `UI/Text` | 字体、文本测量、IME、换行 |
+| `UI/Widgets` | 基础控件和复合控件 |
+| `UI/Markup` | `.xcui` 解析、编译、热重载 |
+| `UI/Schema` | inspector/form schema |
+| `UI/Runtime` | runtime screen/player/系统接入 |
+| `UI/DevTools` | UI 树、layout、style、binding 调试工具 |
+
+---
+
+## 5. 核心设计原则
+
+### 5.1 Retained-mode,而不是即时模式
+
+XCUI 的正式形态是 retained-mode:
+
+- UI 树长期存在
+- 状态变化驱动局部更新
+- 布局与绘制是独立阶段
+- 不以“每帧手写过程式调用”作为主路径
+
+### 5.2 声明式优先,代码式保底
+
+XCUI 应同时支持:
+
+- `Markup-first`
+- `Code-first`
+
+但主路径必须是 `Markup-first`,原因如下:
+
+- 树状 UI 更适合 AI 生成和修改
+- 结构、样式、行为边界更清晰
+- 更适合热重载和资源化
+
+### 5.3 单向数据流
+
+强制采用:
+
+- `State -> View`
+- `UI Event -> Command -> Domain -> State Update -> View Refresh`
+
+禁止:
+
+- Widget 直接到处改业务对象
+- UI 节点绕过命令系统随意操作 `SceneManager`
+- 控件内部偷偷维护大量业务状态
+
+### 5.4 Viewport 是宿主,不是普通控件
+
+`Scene/Game Viewport` 必须视为特殊宿主节点:
+
+- 它承载纹理
+- 它定义输入边界
+- 它连接 viewport 服务
+- 它不负责普通 UI 布局逻辑之外的 gizmo/picking/世界 overlay
+
+### 5.5 Schema 优先于硬编码 Inspector
+
+对于 Inspector、Material、ImportSettings、Script 字段:
+
+- 默认走 schema + auto form
+- 只有少量复杂控件才走 custom section
+
+目标是让“新增/修改一个属性面板”更多是改 schema 和 view,而不是继续写大块 imperative C++。
+
+---
+
+## 6. 编程模型
+
+### 6.1 View / ViewModel / Command
+
+#### View
+
+职责:
+
+- 描述结构
+- 描述布局
+- 描述样式
+- 建立绑定
+- 发出命令意图
+
+不负责:
+
+- 直接修改业务对象
+- 直接处理 domain 规则
+
+#### ViewModel
+
+职责:
+
+- 订阅 editor/runtime 的 domain state
+- 整理成 UI 可绑定状态
+- 组织派生状态
+- 绑定命令和事件
+
+#### Command
+
+职责:
+
+- 把 UI 意图翻译成稳定的业务命令
+- editor 命令落到现有 `Commands::*`
+- runtime 命令落到游戏 UI action handler
+
+### 6.2 推荐命令命名
+
+```text
+Editor.Entity.Create
+Editor.Entity.Delete
+Editor.Entity.Rename
+Editor.Project.Open
+Editor.Project.Refresh
+Editor.Scripting.Rebuild
+Editor.Layout.Reset
+Runtime.Menu.Open
+Runtime.Menu.Close
+Runtime.HUD.Toggle
+```
+
+### 6.3 绑定表达式约束
+
+XCUI 不引入完整脚本表达式引擎。
+
+Markup 中支持的表达式范围只建议包括:
+
+- `bind`
+- `if`
+- `not`
+- `equals`
+- `foreach`
+- `command`
+
+这能保证:
+
+- AI 易于生成
+- 编译器易于诊断
+- runtime 复杂度可控
+
+---
+
+## 7. 文件格式设计
+
+### 7.1 `.xcui`
+
+用于描述 UI 视图树。
+
+示例:
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 7.2 `.xctheme`
+
+用于定义 theme token 和 widget style。
+
+建议结构:
+
+- tokens
+- widget defaults
+- class styles
+- state overrides
+
+### 7.3 `.xcschema`
+
+用于定义属性表单、自动 inspector 渲染规则。
+
+示例:
+
+```xml
+
+
+
+
+
+```
+
+### 7.4 编译产物
+
+导入后建议生成 artifact:
+
+- 视图蓝图二进制
+- style table
+- schema table
+- 依赖信息
+- source mapping
+
+这样可以统一进入 `AssetDatabase/Library` 工作流。
+
+---
+
+## 8. 状态系统设计
+
+### 8.1 状态类型
+
+XCUI 状态分为四层:
+
+1. `Domain State`
+2. `ViewModel State`
+3. `Local UI State`
+4. `Persistent UI State`
+
+#### Domain State
+
+由 editor/runtime 业务层维护,例如:
+
+- 当前选中实体
+- 当前项目目录
+- Play Mode 状态
+- Console 日志集合
+
+#### ViewModel State
+
+由 VM 派生,例如:
+
+- `selection.hasEntity`
+- `selection.hasMaterial`
+- `console.filteredEntries`
+
+#### Local UI State
+
+局部交互态,例如:
+
+- splitter 比例
+- 树节点展开态
+- 输入框草稿值
+- hover/pressed/active
+
+#### Persistent UI State
+
+持久化状态,例如:
+
+- dock 布局
+- tab 顺序
+- 列宽
+- 面板可见性
+- 最近搜索关键词
+
+### 8.2 状态基元
+
+建议核心状态基元:
+
+- `UISignal`
+- `UIComputed`
+- `UIListModel`
+- `UIStore`
+
+目标:
+
+- 局部变化只传播到依赖它的 subtree
+- 不引入全局巨型 store 的维护负担
+- 保持 VM 的表达简单明确
+
+### 8.3 失效传播
+
+建议失效类型:
+
+- `StructureDirty`
+- `StyleDirty`
+- `LayoutDirty`
+- `PaintDirty`
+- `DataDirty`
+
+传播原则:
+
+- 数据变化优先局部传播
+- 布局变化只回溯到需要重排的祖先
+- 样式变化只重建受影响节点的 resolved style
+- 绘制变化只重建对应 draw list 片段
+
+---
+
+## 9. ViewModel 设计
+
+### 9.1 基本形态
+
+示例:
+
+```cpp
+class InspectorPanelVM : public UIViewModel {
+public:
+ UISignal hasSelection;
+ UISignal title;
+ UISignal entity;
+ UISignal material;
+
+ UICommandRef addComponentCommand;
+ UICommandRef rebuildScriptsCommand;
+
+ void Attach(IEditorContext& context) override;
+ void Detach() override;
+};
+```
+
+### 9.2 ViewModel约束
+
+ViewModel 只暴露:
+
+- `Signal`
+- `Computed`
+- `ListModel`
+- `CommandRef`
+- DTO / ViewData
+
+不要直接把这些暴露给 view:
+
+- `GameObject*`
+- `AssetItemPtr`
+- `RHITexture*`
+- `Scene*`
+
+View 层应该看到的是“UI 友好的数据形态”,而不是业务层裸对象。
+
+---
+
+## 10. 布局系统设计
+
+### 10.1 布局理念
+
+XCUI 布局系统只做你需要的 80%:
+
+- 易于理解
+- 易于实现
+- 易于 AI 生成
+- 足够支撑 editor 和 runtime 主流场景
+
+不追求浏览器级 layout feature completeness。
+
+### 10.2 布局单位
+
+建议支持:
+
+- `px`
+- `%`
+- `auto`
+- `fill`
+- `fr`
+- `content`
+
+底层类型可定义为:
+
+```cpp
+enum class UISizeKind : uint8_t {
+ Auto,
+ Px,
+ Percent,
+ Fill,
+ Fraction,
+ Content
+};
+```
+
+### 10.3 基础容器
+
+V1 基础容器:
+
+- `Row`
+- `Column`
+- `Grid`
+- `Overlay`
+- `Scroll`
+- `Split`
+- `Tabs`
+- `DockHost`
+
+### 10.4 基础布局属性
+
+- `width`
+- `height`
+- `minWidth`
+- `minHeight`
+- `maxWidth`
+- `maxHeight`
+- `margin`
+- `padding`
+- `gap`
+- `align`
+- `justify`
+- `clip`
+- `aspectRatio`
+
+### 10.5 关键规则
+
+- 普通 UI 一律优先流式布局
+- 绝对定位只允许在 `Overlay`
+- SceneView 的世界空间 overlay 不走普通 UI 布局系统
+- `DockHost` 属于 editor-only 高级布局容器
+
+---
+
+## 11. 样式与主题系统
+
+### 11.1 样式模型
+
+XCUI 采用:
+
+- token
+- widget type style
+- class style
+- id style
+- state override
+
+不采用完整 CSS cascade。
+
+### 11.2 样式优先级
+
+建议顺序:
+
+1. theme 默认
+2. widget 默认样式
+3. class 样式
+4. id 样式
+5. inline style
+6. state override
+
+### 11.3 Token系统
+
+建议 token 类型:
+
+- color
+- spacing
+- radius
+- border
+- shadow
+- font
+- icon size
+- duration
+- z-layer
+
+示例:
+
+```text
+color.bg.panel
+color.bg.hover
+color.text.primary
+color.text.muted
+color.border.soft
+spacing.1
+spacing.2
+radius.sm
+radius.md
+font.ui
+font.mono
+```
+
+### 11.4 样式表达约束
+
+支持:
+
+- widget type
+- class
+- id
+- state
+
+不支持:
+
+- descendant selector
+- sibling selector
+- 复杂层层 cascade
+- 通配复杂选择器
+
+这是为了把复杂度压下来,保证 AI 和人类都容易维护。
+
+---
+
+## 12. 文本系统设计
+
+文本系统必须从 V1 开始纳入正式设计。
+
+### 12.1 V1 必须支持
+
+- UTF-8
+- 中文
+- 字体 fallback
+- 文本测量
+- 自动换行
+- 裁剪
+- 单行输入
+- 多行输入
+- 选择/复制/粘贴
+- IME
+
+### 12.2 平台策略
+
+Windows-first 阶段建议直接依赖平台文本输入能力处理 IME 与文本输入。
+
+后续抽象为:
+
+- `IUITextBackend`
+- `UITextLayout`
+- `UIFontAtlas`
+
+### 12.3 风险提示
+
+文本和输入法是 editor-grade UI 的硬门槛。
+不能把它当成“后面再说”的附属模块。
+
+---
+
+## 13. 输入系统与焦点系统
+
+### 13.1 输入事件类型
+
+建议标准事件:
+
+- `PointerMove`
+- `PointerDown`
+- `PointerUp`
+- `PointerWheel`
+- `Click`
+- `DoubleClick`
+- `DragStart`
+- `DragMove`
+- `DragDrop`
+- `KeyDown`
+- `KeyUp`
+- `TextInput`
+- `Shortcut`
+- `FocusChanged`
+
+### 13.2 输入分发模型
+
+流程:
+
+1. 命中测试得到 target
+2. target 接收事件
+3. 事件向上 bubble
+4. drag 期间支持 capture
+5. shortcut 基于 focus scope / command scope 分发
+
+### 13.3 焦点模型
+
+建议区分:
+
+- `KeyboardFocus`
+- `PointerHover`
+- `PointerCapture`
+- `ActiveTextInput`
+- `FocusScope`
+- `ModalScope`
+
+### 13.4 与当前 editor 的关系
+
+当前 `EditorActionRoute` 可在过渡期保留。
+中期应逐步把动作路由并入:
+
+- `FocusScope`
+- `CommandScope`
+- `ShortcutManager`
+
+---
+
+## 14. 渲染架构
+
+### 14.1 渲染流程
+
+```text
+View Tree
+ -> Layout Tree
+ -> Paint Tree
+ -> UIDrawList
+ -> Render Backend
+```
+
+### 14.2 DrawList基元
+
+V1 只做这些 primitive:
+
+- filled rect
+- rounded rect
+- border
+- line
+- text
+- image
+- nine-slice image
+- clip push/pop
+- transform push/pop
+- layer push/pop
+
+### 14.3 后端划分
+
+必须有两个后端:
+
+1. `XCUIImGuiBackend`
+2. `XCUIRHIRenderBackend`
+
+#### ImGui Backend
+
+定位:
+
+- 过渡期后端
+- 让 XCUI 先接入当前 editor
+
+职责:
+
+- 把 XCUI draw list 翻译为 ImGui draw list
+- 把 ImGui 输入桥接为 XCUI 输入事件
+
+#### RHI Native Backend
+
+定位:
+
+- 最终正式后端
+- Editor/Runtime 共享
+
+职责:
+
+- 走 XCEngine 自己的 RHI/Rendering 链路
+- 支持 D3D12/OpenGL/Vulkan
+
+### 14.4 性能要求
+
+必须尽早纳入:
+
+- 文本布局缓存
+- 图片 atlas
+- style resolve cache
+- virtualization
+- clip batch 合并
+- dirty subtree 重绘
+
+---
+
+## 15. Widget库设计
+
+### 15.1 基础件
+
+- `Text`
+- `Icon`
+- `Image`
+- `Spacer`
+- `Divider`
+- `Button`
+- `IconButton`
+- `Toggle`
+- `Checkbox`
+- `Radio`
+- `Input`
+- `TextArea`
+- `NumberInput`
+- `Slider`
+- `Dropdown`
+- `ProgressBar`
+- `TagChip`
+
+### 15.2 布局件
+
+- `Row`
+- `Column`
+- `Grid`
+- `Overlay`
+- `Scroll`
+- `Split`
+- `Tabs`
+- `Panel`
+- `Toolbar`
+- `StatusBar`
+
+### 15.3 数据件
+
+- `ListView`
+- `TreeView`
+- `TableView`
+- `VirtualList`
+- `LogView`
+- `Breadcrumb`
+- `SearchBox`
+
+### 15.4 字段件
+
+- `BoolField`
+- `IntField`
+- `FloatField`
+- `Vector2Field`
+- `Vector3Field`
+- `Vector4Field`
+- `ColorField`
+- `EnumField`
+- `AssetField`
+- `ObjectField`
+
+### 15.5 Editor专用件
+
+- `PropertyGrid`
+- `AutoForm`
+- `DockHost`
+- `TabStack`
+- `AssetGrid`
+- `HierarchyTree`
+- `ConsoleView`
+- `ViewportSlot`
+
+### 15.6 Runtime专用件
+
+- `HUDLayer`
+- `MenuStack`
+- `Toast`
+- `Dialogue`
+- `ActionBar`
+
+---
+
+## 16. Schema驱动Inspector体系
+
+### 16.1 设计原则
+
+Inspector 不再以一个巨大 panel 文件承担所有逻辑。
+应改为:
+
+- `Reflection`
+- `Schema`
+- `AutoForm`
+- `CustomSection`
+
+### 16.2 Schema可表达内容
+
+- label
+- category
+- widget type
+- visible
+- enabled
+- tooltip
+- range
+- precision
+- asset filter
+- enum source
+- reset value
+- group
+- custom renderer key
+
+### 16.3 执行策略
+
+- 默认能 auto form 的都 auto form
+- Script 字段优先 schema 驱动
+- Material/ImportSettings 优先 schema 驱动
+- 复杂场景才使用 custom section
+
+### 16.4 与当前项目的衔接
+
+当前以下能力可直接成为 schema 后端基础:
+
+- `ScriptComponentEditor` 的字段模型读取与写回
+- Material inspector 的 render state / texture / shader 数据模型
+
+---
+
+## 17. Docking与Editor Shell
+
+### 17.1 设计目标
+
+XCUI 需要 editor-grade 布局能力,但不应复刻完整 IDE docking 复杂度。
+
+### 17.2 节点类型
+
+- `SplitNode`
+- `TabStackNode`
+- `PanelLeaf`
+
+### 17.3 V1支持
+
+- 左右/上下 split
+- tab 切换
+- tab 重排
+- 面板隐藏/显示
+- layout 持久化
+
+### 17.4 V1不支持
+
+- 全量浮动窗口系统
+- 复杂多显示器浮窗
+- 过重的 docking 动画和规则
+
+### 17.5 Layout持久化
+
+建议 layout 资源单独持久化为 `.xclayout`。
+
+---
+
+## 18. Viewport集成设计
+
+### 18.1 设计原则
+
+Scene/Game Viewport 必须作为特殊宿主节点存在,而不是普通 widget。
+
+### 18.2 `ViewportSlot`职责
+
+- 请求 viewport 纹理
+- 确定可交互区域
+- 接收输入边界
+- 把纹理显示在 UI 布局中
+- 与 ViewModel 协作渲染壳层工具栏和状态信息
+
+### 18.3 `ViewportSlot`不负责
+
+- gizmo 算法
+- object picking 内核
+- world overlay 生成
+- scene outline pass
+
+这些仍应保留在现有 viewport 子系统中。
+
+### 18.4 公共接口改造
+
+当前与 ImGui 耦合的 viewport 接口后续必须抽象为平台无关类型。
+
+建议新增:
+
+- `UITextureHandle`
+- `UIPoint`
+- `UISize`
+- `UIRect`
+- `ViewportFrame`
+
+必须避免在 XCUI 和 editor 公共边界里继续出现:
+
+- `ImTextureID`
+- `ImVec2`
+
+---
+
+## 19. Runtime UI设计
+
+### 19.1 基本模型
+
+Runtime UI 建议采用:
+
+- `UIScreenAsset`
+- `UIScreenPlayer`
+- `UISystem`
+
+### 19.2 第一阶段范围
+
+V1 runtime UI 只做:
+
+- main menu
+- pause menu
+- HUD
+- dialogue
+- settings
+
+默认只支持 screen-space UI。
+
+### 19.3 后续扩展
+
+V2 可考虑:
+
+- world-space UI
+- render-to-texture UI
+- 3D anchor
+
+但必须放到 core 稳定之后。
+
+---
+
+## 20. 资源与导入管线
+
+### 20.1 新资源类型
+
+建议注册:
+
+- `.xcui`
+- `.xctheme`
+- `.xcschema`
+- `.xclayout`
+
+### 20.2 导入流程
+
+1. 解析源文件
+2. 做 schema / style / binding 验证
+3. 编译为 blueprint artifact
+4. 写入依赖清单
+5. 支持热重载
+
+### 20.3 诊断输出
+
+编译器必须输出:
+
+- 文件
+- 行号
+- 列号
+- 错误码
+- 简洁可读的错误信息
+
+这是 AI 和人类能否高效修 UI 资源的关键。
+
+---
+
+## 21. DevTools设计
+
+XCUI 必须内建 DevTools,而不是后补。
+
+V1 必须具备:
+
+- UI tree inspector
+- layout bounds overlay
+- style/token inspector
+- focus chain viewer
+- binding inspector
+- command log
+- event log
+- invalidation overlay
+- hot reload panel
+
+没有 DevTools,XCUI 后期调试成本会迅速失控。
+
+---
+
+## 22. 测试策略
+
+建议新增测试目录:
+
+```text
+tests/UI/Core/
+tests/UI/Runtime/
+tests/UI/Editor/
+```
+
+测试类型包括:
+
+- 单元测试:signals/layout/style/schema
+- 集成测试:VM + command routing
+- 截图回归:控件和面板基线图
+- 性能测试:大列表、树、文本布局
+- 热重载测试:`.xcui/.xctheme/.xcschema`
+- editor 回归:旧命令语义保持一致
+
+---
+
+## 23. 与当前工程的迁移策略
+
+### 23.1 当前关键耦合点
+
+后续迁移时,以下区域是重点:
+
+- `Application` 中的 ImGui 初始化和 frame 驱动
+- `ImGuiBackendBridge`
+- `IViewportHostService` 中的 ImGui 类型
+- `ViewportPanelContent` 中的 ImGui 交互表面和贴图逻辑
+- `GameView` 输入桥中的 ImGui 键鼠采样
+- `Layer::onImGuiRender()` / `LayerStack::onImGuiRender()`
+
+### 23.2 迁移原则
+
+- 不一次性推翻 editor
+- 先把 ImGui 从“架构中心”降为“过渡后端”
+- 先迁最能体现价值的业务面板
+- 先迁 shell 和表单,不先动 scene gizmo 核心算法
+
+### 23.3 推荐迁移顺序
+
+1. `Console`
+2. `Inspector`
+3. `Project`
+4. `Hierarchy`
+5. `Dock shell`
+6. `SceneView/GameView shell`
+7. `Runtime HUD/Menu`
+
+---
+
+## 24. 详细执行计划
+
+### Phase 0:边界清理
+
+时间:2 周
+
+目标:
+
+- 把 ImGui 从 editor 公共接口中压回 adapter 层
+
+任务:
+
+- 定义 `UIPoint/UISize/UIRect/UITextureHandle`
+- 改造 viewport 公共接口去掉 ImGui 类型
+- 重构 GameView 输入桥接口
+- 规划移除 `onImGuiRender` 的 engine-core 设计残留
+
+退出标准:
+
+- editor 业务边界不再暴露 ImGui 类型
+- ImGui 主要停留在宿主和 adapter 层
+
+### Phase 1:XCUI Core MVP
+
+时间:3 周
+
+目标:
+
+- 建立可运行的 XCUI Core
+
+任务:
+
+- `UIRoot/UINode/UIView/UIViewInstance`
+- `UISignal/UIComputed/UIStore`
+- 失效系统
+- `Row/Column/Overlay/Scroll/Split`
+- `UITheme/UIStyleSet`
+- `UIDrawList`
+- 命中测试与焦点管理
+
+退出标准:
+
+- 独立 demo 可运行
+- 按钮、输入框、滚动、splitter 可交互
+- layout 单元测试通过
+
+### Phase 2:ImGui过渡后端
+
+时间:2 周
+
+目标:
+
+- 让 XCUI 在现有 editor 内先跑起来
+
+任务:
+
+- `XCUIImGuiRenderer`
+- `XCUIImGuiInputAdapter`
+- `XCUIHost`
+- 最小调试 overlay
+
+退出标准:
+
+- 当前 editor 内可显示 XCUI demo panel
+- 不影响现有 viewport 主链
+
+### Phase 3:Markup与资源导入
+
+时间:3 周
+
+目标:
+
+- 让 UI 进入资源化和热重载阶段
+
+任务:
+
+- `.xcui` 解析器
+- `.xctheme` 解析器
+- `.xcschema` 解析器
+- blueprint artifact 编译
+- dependency tracking
+- hot reload
+
+退出标准:
+
+- 修改资源文件后 editor 热更新
+- 编译错误能精确定位到行列
+
+### Phase 4:Schema / PropertyGrid / Inspector
+
+时间:4 周
+
+目标:
+
+- 正式打通 editor inspector 体系
+
+任务:
+
+- schema runtime
+- `PropertyGrid`
+- `AutoForm`
+- Transform schema
+- Material schema
+- Script 字段 schema 适配
+- `InspectorVM`
+
+退出标准:
+
+- Inspector 能替代常见字段编辑
+- Material inspector 可用
+- Script 字段编辑链路稳定
+
+### Phase 5:Project / Console / Hierarchy
+
+时间:4 周
+
+目标:
+
+- 迁移 editor 三大主工作流面板
+
+任务:
+
+- `VirtualList`
+- `TreeView`
+- `AssetGrid`
+- `LogView`
+- `SearchBox`
+- `ProjectVM`
+- `ConsoleVM`
+- `HierarchyVM`
+
+退出标准:
+
+- Project/Console/Hierarchy 可日常使用
+- 过滤、拖拽、选择稳定
+
+### Phase 6:DockHost / Menu / Shortcut / Panel Shell
+
+时间:4 周
+
+目标:
+
+- 让 XCUI 承担 editor shell
+
+任务:
+
+- `DockHost`
+- `TabStack`
+- layout persistence
+- `MenuBar`
+- `ContextMenu`
+- `ShortcutManager`
+- `PanelFrame`
+- `StatusBar`
+
+退出标准:
+
+- editor shell 不再依赖 ImGui docking
+- layout 可保存/恢复
+
+### Phase 7:ViewportSlot 与 Scene/Game Shell
+
+时间:3 周
+
+目标:
+
+- 迁移 Scene/Game 面板外壳
+
+任务:
+
+- `ViewportSlot`
+- `SceneViewVM`
+- `GameViewVM`
+- XCUI 输入桥接
+- 工具栏、状态条、壳层布局迁移
+
+退出标准:
+
+- Scene/Game 面板 UI 外壳由 XCUI 驱动
+- viewport 内核仍复用原有系统
+
+### Phase 8:RHI原生后端
+
+时间:6 周
+
+目标:
+
+- 让 XCUI 成为真正 native UI renderer
+
+任务:
+
+- `XCUIRHIRenderBackend`
+- 字体 atlas / 文本渲染
+- 图像、clip、popup、tooltip
+- 统一输入与焦点
+- editor 主循环直接渲染 XCUI draw list
+
+退出标准:
+
+- editor 主 UI 可不依赖 ImGui
+- XCUI 成为正式渲染路径
+
+### Phase 9:Runtime UI
+
+时间:4 周
+
+目标:
+
+- 让 XCUI 成为游戏运行时 UI 方案
+
+任务:
+
+- `UIScreenAsset`
+- `UIScreenPlayer`
+- runtime 输入桥接
+- HUD/menu 示例
+- 可选 C# wrapper
+
+退出标准:
+
+- runtime HUD/menu 正式运行
+- editor/runtime 共用同一套 UI Core
+
+---
+
+
+## 24.1 当前执行优先级修正(2026-04-06)
+
+近期执行顺序调整如下:
+
+1. 冻结当前基于 ImGui 的 `editor/` 目录,不把它作为本阶段 XCUI 替换开发的主工作区。
+2. 新的 XCUI `Editor` 层先在 `new_editor/` 中构建。
+3. `tests/UI` 继续只做验证,不承载正式 editor 实现。
+4. 在具体 editor 面板之前,优先完成 editor shell 基础能力:
+ - Splitter / pane resize
+ - Tab strip
+ - Workspace compose
+ - Hierarchy / Inspector / Console 所需的 editor-only 基础件
+5. `Runtime UI` 仍然是 XCUI 的长期目标,但在 editor shell 替换路径稳定之前,不作为当前主线。
+
+### 24.2 Core当前阶段判断与Editor推进边界(2026-04-06)
+
+当前判断:
+
+- `Core` 第一阶段已经基本收口,可以继续推进 `Editor` 基础层。
+- 但这不等于 `Core` 已经最终封板;后续推进 `Editor` 时,仍然可能暴露新的共享能力缺口。
+
+执行硬规则:
+
+- `Editor` 当前阶段只负责 editor shell、panel 生命周期、workspace 装配、menu/shortcut 这类 editor-only 上层能力。
+- 凡是发现 `layout / input / style / text / render contract / shared widget` 等共享能力缺口,必须优先回补到 `Core` 或 shared UI 层。
+- 禁止在 `Editor` 层硬写临时替代实现去绕过 `Core` 缺口;否则后面迁移到正式 editor 时会再次返工。
+- `new_editor/` 当前只作为 `Editor` 层实现载体与试验场,不作为业务面板堆叠区。
+- `tests/UI/Editor` 当前只验证 `Editor` 基础壳层与状态流,不提前承担具体业务面板复刻。
+
+### 24.3 Editor基础层当前推进顺序(2026-04-06)
+
+在不提前进入业务面板的前提下,当前主线顺序固定为:
+
+1. 完善 `Panel Registry / Descriptor`,让面板元数据、展示语义、默认能力声明先稳定。
+2. 建立 `Workspace Session / Panel State` 装配层,明确 panel instance、visible/hidden、active、pinned 等状态归属。
+3. 打通 `open / close / show / hide / activate` 的基础状态流,并让 tab 选择与 active panel 始终一致。
+4. 先补 `tests/UI/Editor/unit` 对上述状态机与装配规则的覆盖,再补一个专注状态流的 `integration` 场景。
+5. 上述基础层稳定后,再继续推进 `MenuBar / ShortcutManager / DockHost layout persistence`,最后才轮到具体业务面板迁移。
+## 25. 人力与节奏建议
+
+若按 1 名主导工程师 + AI 辅助估算:
+
+- `MVP`:10 到 14 周
+- `editor 大部分面板迁移`:18 到 24 周
+- `editor 基本脱离 ImGui`:24 到 32 周
+- `runtime UI 接入`:额外 6 到 10 周
+
+如果中途扩 scope 到以下内容,周期会明显拉长:
+
+- world-space UI
+- 复杂浮动窗口
+- 动画系统
+- 富文本编辑器
+- 多平台文本后端一次性铺开
+
+---
+
+## 26. 主要风险与约束
+
+### 26.1 主要风险
+
+- 试图复刻 HTML/CSS,导致 scope 爆炸
+- 过早做复杂 docking
+- 低估文本输入和 IME
+- Inspector 又重新退化成大块硬编码 C++
+- 在 Core 未稳定时就推进 world-space UI
+- 太早把 C# UI 脚本化纳入主路径
+
+### 26.2 约束原则
+
+- 不做完整 CSS
+- V1 不做 world-space UI
+- Docking 先做 split + tabs
+- 表单优先 schema 化
+- 先迁 shell,后动 viewport 内核
+- C# 绑定放到后期
+
+---
+
+## 27. 里程碑定义
+
+### Milestone A:XCUI Core 跑通
+
+条件:
+
+- Core MVP 完成
+- ImGui 过渡后端可运行 demo
+- `.xcui` 能加载静态界面
+
+### Milestone B:Inspector体系迁移成功
+
+条件:
+
+- PropertyGrid + AutoForm 可用
+- Transform/Material/Script 字段链路打通
+
+### Milestone C:Editor主面板迁移完成
+
+条件:
+
+- Console/Inspector/Project/Hierarchy 迁移到 XCUI
+
+### Milestone D:Editor Shell 脱离 ImGui Docking
+
+条件:
+
+- DockHost + Menu + Shortcut + PanelFrame 稳定
+
+### Milestone E:Editor 主 UI 脱离 ImGui
+
+条件:
+
+- XCUI 原生后端可承担 editor 主界面
+
+### Milestone F:Runtime UI 正式接入
+
+条件:
+
+- runtime HUD/menu 使用 XCUI
+- editor/runtime 共用 UI Core
+
+---
+
+## 28. 最终拍板建议
+
+如果按本文档执行,最终建议如下:
+
+- 把 `XCUI` 定义为“引擎级统一 UI 平台”
+- 先做 `Core + ImGui Adapter`
+- 先迁 `Console / Inspector / Project`
+- 中期完成 `DockHost + ViewportSlot`
+- 后期完成 `RHI Native Backend`
+- 最后把 runtime UI 全面接入
+
+XCUI 的最终目标不是“拥有一套新 UI API”,而是让 XCEngine 的 UI 体系从现在的过程式宿主代码,演进成:
+
+- 可组合
+- 可资源化
+- 可调试
+- 可热重载
+- 可由 AI 稳定维护
+- 可同时服务 editor/runtime 的正式平台层
+
+---
+
+## 29. 当前结论
+
+基于 XCEngine 当前的 editor、viewport、asset、scripting 结构,XCUI 是一条可行且值得投入的路线。
+
+最合理的执行方式不是“一次性替掉 ImGui”,而是:
+
+1. 先把 ImGui 降级为过渡后端
+2. 用 XCUI 承担新的 panel/view/form 体系
+3. 再逐步收回 editor shell
+4. 最后完成原生 RHI UI 后端
+
+这条路线既能保护现有主链不被打断,也能逐步把 UI 层从“AI 不友好的 native imperative 代码”演进为“AI 友好的声明式原生 UI 平台”。
diff --git a/engine/include/XCEngine/UI/Runtime/UIScreenTypes.h b/engine/include/XCEngine/UI/Runtime/UIScreenTypes.h
index b1bc4d59..8e2c28ba 100644
--- a/engine/include/XCEngine/UI/Runtime/UIScreenTypes.h
+++ b/engine/include/XCEngine/UI/Runtime/UIScreenTypes.h
@@ -1,6 +1,8 @@
#pragma once
#include
+#include
+#include
#include
#include
@@ -30,7 +32,10 @@ struct UIScreenDocument {
std::vector dependencies = {};
Resources::UIDocumentModel viewDocument = {};
Resources::UIDocumentModel themeDocument = {};
+ Style::UITheme runtimeTheme = {};
+ Style::UIStyleSheet runtimeStyleSheet = {};
bool hasThemeDocument = false;
+ bool hasRuntimeTheme = false;
bool IsValid() const {
return !sourcePath.empty();
@@ -39,6 +44,14 @@ struct UIScreenDocument {
const Resources::UIDocumentModel* GetThemeDocument() const {
return hasThemeDocument ? &themeDocument : nullptr;
}
+
+ const Style::UITheme* GetRuntimeTheme() const {
+ return hasRuntimeTheme ? &runtimeTheme : nullptr;
+ }
+
+ const Style::UIStyleSheet* GetRuntimeStyleSheet() const {
+ return hasRuntimeTheme ? &runtimeStyleSheet : nullptr;
+ }
};
struct UIScreenLoadResult {
diff --git a/engine/include/XCEngine/UI/Style/DocumentStyleCompiler.h b/engine/include/XCEngine/UI/Style/DocumentStyleCompiler.h
new file mode 100644
index 00000000..6d4e4828
--- /dev/null
+++ b/engine/include/XCEngine/UI/Style/DocumentStyleCompiler.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include "StyleResolver.h"
+
+#include
+
+#include
+
+namespace XCEngine {
+namespace UI {
+namespace Style {
+
+struct UIDocumentStyleCompileResult {
+ bool succeeded = false;
+ std::string errorMessage = {};
+ UITheme theme = {};
+ UIStyleSheet styleSheet = {};
+};
+
+UIDocumentStyleCompileResult CompileDocumentStyle(
+ const Resources::UIDocumentModel& themeDocument);
+
+bool TryCompileDocumentStyle(
+ const Resources::UIDocumentModel& themeDocument,
+ UITheme& outTheme,
+ UIStyleSheet& outStyleSheet,
+ std::string* outErrorMessage = nullptr);
+
+UIStyleSet BuildInlineStyle(const Resources::UIDocumentNode& node);
+
+} // namespace Style
+} // namespace UI
+} // namespace XCEngine
diff --git a/engine/src/UI/Runtime/UIScreenDocumentHost.cpp b/engine/src/UI/Runtime/UIScreenDocumentHost.cpp
index dd92dd3e..c39da0e5 100644
--- a/engine/src/UI/Runtime/UIScreenDocumentHost.cpp
+++ b/engine/src/UI/Runtime/UIScreenDocumentHost.cpp
@@ -6,6 +6,7 @@
#include
#include
#include
+#include
#include
#include
@@ -34,6 +35,7 @@ using XCEngine::Resources::UIDocumentCompileResult;
using XCEngine::Resources::UIDocumentKind;
using XCEngine::Resources::UIDocumentNode;
namespace Layout = XCEngine::UI::Layout;
+namespace Style = XCEngine::UI::Style;
constexpr float kDefaultFontSize = 16.0f;
constexpr float kSmallFontSize = 13.0f;
@@ -78,6 +80,8 @@ struct RuntimeLayoutNode {
bool tabSelected = false;
bool hasShortcutBinding = false;
UIShortcutBinding shortcutBinding = {};
+ Style::UIStyleSet localStyle = {};
+ Style::UIResolvedStyle resolvedStyle = {};
enum class ShortcutScopeRoot : std::uint8_t {
None = 0,
Window,
@@ -580,6 +584,166 @@ Layout::UILayoutThickness ParsePadding(
return Layout::UILayoutThickness::Uniform(ParseFloatAttribute(node, "padding", fallback));
}
+Layout::UILayoutThickness ToLayoutThickness(const Style::UIThickness& thickness) {
+ return Layout::UILayoutThickness(
+ thickness.left,
+ thickness.top,
+ thickness.right,
+ thickness.bottom);
+}
+
+const Style::UIStylePropertyResolution* FindResolvedProperty(
+ const RuntimeLayoutNode& node,
+ Style::UIStylePropertyId propertyId) {
+ return node.resolvedStyle.FindProperty(propertyId);
+}
+
+float ResolveNodeFontSize(
+ const RuntimeLayoutNode& node,
+ float fallback) {
+ const Style::UIStylePropertyResolution* resolution =
+ FindResolvedProperty(node, Style::UIStylePropertyId::FontSize);
+ if (resolution == nullptr) {
+ return fallback;
+ }
+
+ if (const float* value = resolution->value.TryGetFloat()) {
+ return (std::max)(1.0f, *value);
+ }
+
+ return fallback;
+}
+
+float ResolveNodeGap(
+ const RuntimeLayoutNode& node,
+ float fallback) {
+ const Style::UIStylePropertyResolution* resolution =
+ FindResolvedProperty(node, Style::UIStylePropertyId::Gap);
+ if (resolution == nullptr) {
+ return fallback;
+ }
+
+ if (const float* value = resolution->value.TryGetFloat()) {
+ return (std::max)(0.0f, *value);
+ }
+
+ return fallback;
+}
+
+Layout::UILayoutThickness ResolveNodePadding(
+ const RuntimeLayoutNode& node,
+ float fallback) {
+ const Style::UIStylePropertyResolution* resolution =
+ FindResolvedProperty(node, Style::UIStylePropertyId::Padding);
+ if (resolution == nullptr) {
+ return Layout::UILayoutThickness::Uniform(fallback);
+ }
+
+ if (const Style::UIThickness* thickness = resolution->value.TryGetThickness()) {
+ return ToLayoutThickness(*thickness);
+ }
+
+ return Layout::UILayoutThickness::Uniform(fallback);
+}
+
+float ResolveNodeBorderWidth(
+ const RuntimeLayoutNode& node,
+ float fallback) {
+ const Style::UIStylePropertyResolution* resolution =
+ FindResolvedProperty(node, Style::UIStylePropertyId::BorderWidth);
+ if (resolution == nullptr) {
+ return fallback;
+ }
+
+ if (const float* value = resolution->value.TryGetFloat()) {
+ return (std::max)(0.0f, *value);
+ }
+
+ return fallback;
+}
+
+float ResolveNodeCornerRadius(
+ const RuntimeLayoutNode& node,
+ float fallback) {
+ const Style::UIStylePropertyResolution* resolution =
+ FindResolvedProperty(node, Style::UIStylePropertyId::CornerRadius);
+ if (resolution == nullptr) {
+ return fallback;
+ }
+
+ if (const Style::UICornerRadius* radius = resolution->value.TryGetCornerRadius()) {
+ return (std::max)({
+ 0.0f,
+ radius->topLeft,
+ radius->topRight,
+ radius->bottomRight,
+ radius->bottomLeft
+ });
+ }
+
+ return fallback;
+}
+
+Color AdjustColorBrightness(const Color& color, float delta) {
+ auto clampChannel = [delta](float value) {
+ return (std::clamp)(value + delta, 0.0f, 1.0f);
+ };
+
+ return Color(
+ clampChannel(color.r),
+ clampChannel(color.g),
+ clampChannel(color.b),
+ color.a);
+}
+
+Color ResolveLegacyBaseBackgroundColor(const UIDocumentNode& node) {
+ const std::string tone = GetAttribute(node, "tone");
+ const std::string tagName = ToStdString(node.tagName);
+
+ if (tagName == "View") {
+ return Color(0.11f, 0.11f, 0.11f, 1.0f);
+ }
+ if (tone == "accent") {
+ return Color(0.25f, 0.25f, 0.25f, 1.0f);
+ }
+ if (tone == "accent-alt") {
+ return Color(0.22f, 0.22f, 0.22f, 1.0f);
+ }
+ if (tagName == "Button") {
+ return Color(0.24f, 0.24f, 0.24f, 1.0f);
+ }
+
+ return Color(0.16f, 0.16f, 0.16f, 1.0f);
+}
+
+Color ResolveLegacyBaseBorderColor(const UIDocumentNode& node) {
+ const std::string tone = GetAttribute(node, "tone");
+ if (tone == "accent") {
+ return Color(0.42f, 0.42f, 0.42f, 1.0f);
+ }
+ if (tone == "accent-alt") {
+ return Color(0.34f, 0.34f, 0.34f, 1.0f);
+ }
+
+ return Color(0.30f, 0.30f, 0.30f, 1.0f);
+}
+
+Color ResolveStyledColor(
+ const RuntimeLayoutNode& node,
+ Style::UIStylePropertyId propertyId,
+ const Color& fallback) {
+ const Style::UIStylePropertyResolution* resolution = FindResolvedProperty(node, propertyId);
+ if (resolution == nullptr) {
+ return fallback;
+ }
+
+ if (const Color* color = resolution->value.TryGetColor()) {
+ return *color;
+ }
+
+ return fallback;
+}
+
Layout::UILayoutItem BuildLayoutItem(
const RuntimeLayoutNode& child,
Layout::UILayoutAxis parentAxis,
@@ -1283,61 +1447,68 @@ UIInputPath ResolveHoveredPath(
}
Color ResolveBackgroundColor(
- const UIDocumentNode& node,
+ const RuntimeLayoutNode& node,
const RuntimeNodeVisualState& state) {
- const std::string tone = GetAttribute(node, "tone");
- const std::string tagName = ToStdString(node.tagName);
-
- if (tagName == "View") {
- return Color(0.11f, 0.11f, 0.11f, 1.0f);
- }
- if (tone == "accent") {
- return Color(0.25f, 0.25f, 0.25f, 1.0f);
- }
- if (tone == "accent-alt") {
- return Color(0.22f, 0.22f, 0.22f, 1.0f);
- }
+ const std::string tagName = ToStdString(node.source->tagName);
+ const Color baseColor = ResolveStyledColor(
+ node,
+ Style::UIStylePropertyId::BackgroundColor,
+ ResolveLegacyBaseBackgroundColor(*node.source));
if (tagName == "Button") {
if (state.active || state.capture) {
- return Color(0.30f, 0.30f, 0.30f, 1.0f);
+ return AdjustColorBrightness(baseColor, 0.06f);
}
if (state.hovered) {
- return Color(0.27f, 0.27f, 0.27f, 1.0f);
+ return AdjustColorBrightness(baseColor, 0.03f);
}
- return Color(0.24f, 0.24f, 0.24f, 1.0f);
}
- return Color(0.16f, 0.16f, 0.16f, 1.0f);
+ return baseColor;
}
Color ResolveBorderColor(
- const UIDocumentNode& node,
+ const RuntimeLayoutNode& node,
const RuntimeNodeVisualState& state) {
+ const Color baseColor = ResolveStyledColor(
+ node,
+ Style::UIStylePropertyId::BorderColor,
+ ResolveLegacyBaseBorderColor(*node.source));
if (state.capture) {
- return Color(0.82f, 0.82f, 0.82f, 1.0f);
+ return AdjustColorBrightness(baseColor, 0.40f);
}
if (state.focused || state.active) {
- return Color(0.62f, 0.62f, 0.62f, 1.0f);
+ return AdjustColorBrightness(baseColor, 0.24f);
}
if (state.hovered) {
- return Color(0.45f, 0.45f, 0.45f, 1.0f);
+ return AdjustColorBrightness(baseColor, 0.12f);
}
- const std::string tone = GetAttribute(node, "tone");
- if (tone == "accent") {
- return Color(0.42f, 0.42f, 0.42f, 1.0f);
- }
- if (tone == "accent-alt") {
- return Color(0.34f, 0.34f, 0.34f, 1.0f);
- }
-
- return Color(0.30f, 0.30f, 0.30f, 1.0f);
+ return baseColor;
}
-float ResolveBorderThickness(const RuntimeNodeVisualState& state) {
- return (state.focused || state.active || state.capture) ? 2.0f : 1.0f;
+float ResolveBorderThickness(
+ const RuntimeLayoutNode& node,
+ const RuntimeNodeVisualState& state) {
+ const float baseThickness = ResolveNodeBorderWidth(node, 1.0f);
+ if (state.focused || state.active || state.capture) {
+ return (std::max)(baseThickness, 2.0f);
+ }
+
+ return baseThickness;
+}
+
+Color ResolveForegroundColor(
+ const RuntimeLayoutNode& node,
+ const RuntimeNodeVisualState& state,
+ const Color& fallback) {
+ Color color = ResolveStyledColor(node, Style::UIStylePropertyId::ForegroundColor, fallback);
+ if (ToStdString(node.source->tagName) == "Button" &&
+ (state.capture || state.focused || state.active)) {
+ color = AdjustColorBrightness(color, 0.08f);
+ }
+ return color;
}
bool IsPathTarget(
@@ -1360,6 +1531,7 @@ RuntimeNodeVisualState ResolveNodeVisualState(
RuntimeLayoutNode BuildLayoutTree(
const UIDocumentNode& source,
+ const UIScreenDocument& document,
const std::string& parentStateKey,
const UIInputPath& parentInputPath,
std::size_t siblingIndex,
@@ -1388,10 +1560,19 @@ RuntimeLayoutNode BuildLayoutTree(
ParseRatioAttribute(source, "ratio", 0.5f));
node.shortcutScopeRoot = ParseShortcutScopeRoot(source);
node.hasShortcutBinding = TryBuildShortcutBinding(source, node.elementId, node.shortcutBinding);
+ node.localStyle = Style::BuildInlineStyle(source);
+ Style::UIStyleResolveContext styleContext = {};
+ styleContext.theme = document.GetRuntimeTheme();
+ styleContext.styleSheet = document.GetRuntimeStyleSheet();
+ styleContext.selector.typeName = tagName;
+ styleContext.selector.styleName = GetAttribute(source, "style");
+ styleContext.localStyle = &node.localStyle;
+ node.resolvedStyle = Style::ResolveStyle(styleContext);
node.children.reserve(source.children.Size());
for (std::size_t index = 0; index < source.children.Size(); ++index) {
node.children.push_back(BuildLayoutTree(
source.children[index],
+ document,
node.stateKey,
node.inputPath,
index,
@@ -1417,17 +1598,22 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
if (tagName == "Text") {
const std::string text = ResolveNodeText(source);
+ const float fontSize = ResolveNodeFontSize(node, kDefaultFontSize);
node.desiredSize = UISize(
- MeasureTextWidth(text, kDefaultFontSize),
- MeasureTextHeight(kDefaultFontSize));
+ MeasureTextWidth(text, fontSize),
+ MeasureTextHeight(fontSize));
node.minimumSize = node.desiredSize;
return node.desiredSize;
}
if (!IsContainerTag(source)) {
+ const float fontSize = ResolveNodeFontSize(
+ node,
+ tagName == "Button" ? kButtonFontSize : kDefaultFontSize);
+ const Layout::UILayoutThickness padding = ResolveNodePadding(node, 12.0f);
node.desiredSize = UISize(
- (std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + 24.0f),
- 44.0f);
+ (std::max)(160.0f, MeasureTextWidth(ResolveNodeText(source), fontSize) + padding.Horizontal()),
+ (std::max)(44.0f, MeasureTextHeight(fontSize) + padding.Vertical()));
node.minimumSize = node.desiredSize;
return node.desiredSize;
}
@@ -1532,12 +1718,11 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
options.axis = IsHorizontalTag(tagName)
? Layout::UILayoutAxis::Horizontal
: Layout::UILayoutAxis::Vertical;
- options.spacing = ParseFloatAttribute(
- source,
- "gap",
+ options.spacing = ResolveNodeGap(
+ node,
options.axis == Layout::UILayoutAxis::Horizontal ? 10.0f : 8.0f);
- options.padding = ParsePadding(
- source,
+ options.padding = ResolveNodePadding(
+ node,
tagName == "View" ? 16.0f : 12.0f);
std::vector desiredItems = {};
@@ -1676,12 +1861,11 @@ void ArrangeNode(
options.axis = IsHorizontalTag(tagName)
? Layout::UILayoutAxis::Horizontal
: Layout::UILayoutAxis::Vertical;
- options.spacing = ParseFloatAttribute(
- source,
- "gap",
+ options.spacing = ResolveNodeGap(
+ node,
options.axis == Layout::UILayoutAxis::Horizontal ? 10.0f : 8.0f);
- options.padding = ParsePadding(
- source,
+ options.padding = ResolveNodePadding(
+ node,
tagName == "View" ? 16.0f : 12.0f);
const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source);
@@ -2414,15 +2598,16 @@ void EmitNode(
++stats.nodeCount;
if (tagName == "View" || tagName == "Card" || tagName == "Button") {
- drawList.AddFilledRect(node.rect, ToUIColor(ResolveBackgroundColor(source, visualState)), 10.0f);
+ const float cornerRadius = ResolveNodeCornerRadius(node, 10.0f);
+ drawList.AddFilledRect(node.rect, ToUIColor(ResolveBackgroundColor(node, visualState)), cornerRadius);
++stats.filledRectCommandCount;
if (tagName != "View") {
drawList.AddRectOutline(
node.rect,
- ToUIColor(ResolveBorderColor(source, visualState)),
- ResolveBorderThickness(visualState),
- 10.0f);
+ ToUIColor(ResolveBorderColor(node, visualState)),
+ ResolveBorderThickness(node, visualState),
+ cornerRadius);
}
}
@@ -2539,22 +2724,27 @@ void EmitNode(
}
if (tagName == "Text") {
+ const float fontSize = ResolveNodeFontSize(node, kDefaultFontSize);
drawList.AddText(
UIPoint(node.rect.x, node.rect.y),
ResolveNodeText(source),
- ToUIColor(Color(0.92f, 0.94f, 0.97f, 1.0f)),
- kDefaultFontSize);
+ ToUIColor(ResolveForegroundColor(node, visualState, Color(0.92f, 0.94f, 0.97f, 1.0f))),
+ fontSize);
++stats.textCommandCount;
}
if (tagName == "Button" && title.empty() && subtitle.empty()) {
+ const float fontSize = ResolveNodeFontSize(node, kButtonFontSize);
drawList.AddText(
- UIPoint(node.rect.x + 12.0f, ComputeCenteredTextTop(node.rect, kButtonFontSize)),
+ UIPoint(node.rect.x + 12.0f, ComputeCenteredTextTop(node.rect, fontSize)),
ResolveNodeText(source),
- ToUIColor(visualState.capture || visualState.focused
- ? Color(1.0f, 1.0f, 1.0f, 1.0f)
- : Color(0.95f, 0.97f, 1.0f, 1.0f)),
- kButtonFontSize);
+ ToUIColor(ResolveForegroundColor(
+ node,
+ visualState,
+ visualState.capture || visualState.focused
+ ? Color(1.0f, 1.0f, 1.0f, 1.0f)
+ : Color(0.95f, 0.97f, 1.0f, 1.0f))),
+ fontSize);
++stats.textCommandCount;
}
@@ -2667,6 +2857,19 @@ UIScreenLoadResult UIDocumentScreenHost::LoadScreen(const UIScreenAsset& asset)
result.document.themeDocument = themeResult.document;
result.document.hasThemeDocument = true;
+ std::string runtimeThemeError = {};
+ if (!Style::TryCompileDocumentStyle(
+ result.document.themeDocument,
+ result.document.runtimeTheme,
+ result.document.runtimeStyleSheet,
+ &runtimeThemeError)) {
+ result = {};
+ result.errorMessage = runtimeThemeError.empty()
+ ? "Failed to compile runtime UI theme styles."
+ : runtimeThemeError;
+ return result;
+ }
+ result.document.hasRuntimeTheme = true;
if (seenDependencies.insert(asset.themePath).second) {
result.document.dependencies.push_back(asset.themePath);
}
@@ -2693,6 +2896,7 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
: document.sourcePath;
RuntimeLayoutNode root = BuildLayoutTree(
document.viewDocument.rootNode,
+ document,
stateRoot,
UIInputPath(),
0u,
diff --git a/engine/src/UI/Style/DocumentStyleCompiler.cpp b/engine/src/UI/Style/DocumentStyleCompiler.cpp
new file mode 100644
index 00000000..d5b16c42
--- /dev/null
+++ b/engine/src/UI/Style/DocumentStyleCompiler.cpp
@@ -0,0 +1,598 @@
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace XCEngine {
+namespace UI {
+namespace Style {
+
+namespace {
+
+using XCEngine::Math::Color;
+using XCEngine::Resources::UIDocumentAttribute;
+using XCEngine::Resources::UIDocumentModel;
+using XCEngine::Resources::UIDocumentNode;
+
+std::string ToStdString(const Containers::String& value) {
+ return value.Empty() || value.CStr() == nullptr
+ ? std::string()
+ : std::string(value.CStr());
+}
+
+std::string TrimAscii(std::string value) {
+ std::size_t start = 0u;
+ while (start < value.size() &&
+ std::isspace(static_cast(value[start])) != 0) {
+ ++start;
+ }
+
+ std::size_t end = value.size();
+ while (end > start &&
+ std::isspace(static_cast(value[end - 1u])) != 0) {
+ --end;
+ }
+
+ return value.substr(start, end - start);
+}
+
+std::string ToLowerAscii(std::string value) {
+ std::transform(
+ value.begin(),
+ value.end(),
+ value.begin(),
+ [](unsigned char ch) {
+ return static_cast(std::tolower(ch));
+ });
+ return value;
+}
+
+const UIDocumentAttribute* FindAttribute(const UIDocumentNode& node, const char* name) {
+ for (const UIDocumentAttribute& attribute : node.attributes) {
+ if (attribute.name == name) {
+ return &attribute;
+ }
+ }
+
+ return nullptr;
+}
+
+std::string GetAttribute(
+ const UIDocumentNode& node,
+ const char* name,
+ const std::string& fallback = {}) {
+ const UIDocumentAttribute* attribute = FindAttribute(node, name);
+ return attribute != nullptr ? ToStdString(attribute->value) : fallback;
+}
+
+bool TryParseFloat(const std::string& text, float& outValue) {
+ const std::string trimmed = TrimAscii(text);
+ if (trimmed.empty()) {
+ return false;
+ }
+
+ char* end = nullptr;
+ const float value = std::strtof(trimmed.c_str(), &end);
+ if (end == trimmed.c_str()) {
+ return false;
+ }
+
+ while (*end != '\0') {
+ if (std::isspace(static_cast(*end)) == 0) {
+ return false;
+ }
+ ++end;
+ }
+
+ outValue = value;
+ return true;
+}
+
+int HexToInt(char ch) {
+ if (ch >= '0' && ch <= '9') {
+ return ch - '0';
+ }
+ if (ch >= 'a' && ch <= 'f') {
+ return 10 + (ch - 'a');
+ }
+ if (ch >= 'A' && ch <= 'F') {
+ return 10 + (ch - 'A');
+ }
+ return -1;
+}
+
+bool TryParseHexByte(std::string_view text, std::uint8_t& outValue) {
+ if (text.size() != 2u) {
+ return false;
+ }
+
+ const int high = HexToInt(text[0]);
+ const int low = HexToInt(text[1]);
+ if (high < 0 || low < 0) {
+ return false;
+ }
+
+ outValue = static_cast((high << 4) | low);
+ return true;
+}
+
+bool TryParseColorValue(const std::string& text, Color& outColor) {
+ const std::string trimmed = TrimAscii(text);
+ if (trimmed.size() != 7u && trimmed.size() != 9u) {
+ return false;
+ }
+ if (trimmed.front() != '#') {
+ return false;
+ }
+
+ std::uint8_t r = 0u;
+ std::uint8_t g = 0u;
+ std::uint8_t b = 0u;
+ std::uint8_t a = 255u;
+ if (!TryParseHexByte(std::string_view(trimmed).substr(1u, 2u), r) ||
+ !TryParseHexByte(std::string_view(trimmed).substr(3u, 2u), g) ||
+ !TryParseHexByte(std::string_view(trimmed).substr(5u, 2u), b)) {
+ return false;
+ }
+
+ if (trimmed.size() == 9u &&
+ !TryParseHexByte(std::string_view(trimmed).substr(7u, 2u), a)) {
+ return false;
+ }
+
+ constexpr float kInv255 = 1.0f / 255.0f;
+ outColor = Color(
+ static_cast(r) * kInv255,
+ static_cast(g) * kInv255,
+ static_cast(b) * kInv255,
+ static_cast(a) * kInv255);
+ return true;
+}
+
+bool TryParseFloatList(const std::string& text, std::vector& outValues) {
+ outValues.clear();
+
+ std::string token = {};
+ auto flushToken = [&]() {
+ if (token.empty()) {
+ return true;
+ }
+
+ float value = 0.0f;
+ if (!TryParseFloat(token, value)) {
+ return false;
+ }
+
+ outValues.push_back(value);
+ token.clear();
+ return true;
+ };
+
+ for (char ch : text) {
+ if (std::isspace(static_cast(ch)) != 0 ||
+ ch == ',' ||
+ ch == ';') {
+ if (!flushToken()) {
+ return false;
+ }
+ continue;
+ }
+
+ token.push_back(ch);
+ }
+
+ return flushToken() && !outValues.empty();
+}
+
+bool TryParseThicknessValue(const std::string& text, UIThickness& outThickness) {
+ std::vector values = {};
+ if (!TryParseFloatList(text, values)) {
+ return false;
+ }
+
+ if (values.size() == 1u) {
+ outThickness = UIThickness::Uniform(values[0]);
+ return true;
+ }
+
+ if (values.size() == 2u) {
+ const float vertical = values[0];
+ const float horizontal = values[1];
+ outThickness = UIThickness{ horizontal, vertical, horizontal, vertical };
+ return true;
+ }
+
+ if (values.size() == 4u) {
+ const float top = values[0];
+ const float right = values[1];
+ const float bottom = values[2];
+ const float left = values[3];
+ outThickness = UIThickness{ left, top, right, bottom };
+ return true;
+ }
+
+ return false;
+}
+
+bool TryParseCornerRadiusValue(const std::string& text, UICornerRadius& outRadius) {
+ std::vector values = {};
+ if (!TryParseFloatList(text, values)) {
+ return false;
+ }
+
+ if (values.size() == 1u) {
+ outRadius = UICornerRadius::Uniform(values[0]);
+ return true;
+ }
+
+ if (values.size() == 2u) {
+ outRadius = UICornerRadius{ values[0], values[1], values[0], values[1] };
+ return true;
+ }
+
+ if (values.size() == 4u) {
+ outRadius = UICornerRadius{ values[0], values[1], values[2], values[3] };
+ return true;
+ }
+
+ return false;
+}
+
+bool TryMapPropertyName(const std::string& name, UIStylePropertyId& outPropertyId) {
+ const std::string normalized = ToLowerAscii(TrimAscii(name));
+ if (normalized == "background" || normalized == "backgroundcolor") {
+ outPropertyId = UIStylePropertyId::BackgroundColor;
+ return true;
+ }
+ if (normalized == "foreground" || normalized == "foregroundcolor" || normalized == "textcolor") {
+ outPropertyId = UIStylePropertyId::ForegroundColor;
+ return true;
+ }
+ if (normalized == "bordercolor") {
+ outPropertyId = UIStylePropertyId::BorderColor;
+ return true;
+ }
+ if (normalized == "borderwidth") {
+ outPropertyId = UIStylePropertyId::BorderWidth;
+ return true;
+ }
+ if (normalized == "radius" || normalized == "cornerradius") {
+ outPropertyId = UIStylePropertyId::CornerRadius;
+ return true;
+ }
+ if (normalized == "padding") {
+ outPropertyId = UIStylePropertyId::Padding;
+ return true;
+ }
+ if (normalized == "gap" || normalized == "spacing") {
+ outPropertyId = UIStylePropertyId::Gap;
+ return true;
+ }
+ if (normalized == "fontsize") {
+ outPropertyId = UIStylePropertyId::FontSize;
+ return true;
+ }
+ if (normalized == "linewidth") {
+ outPropertyId = UIStylePropertyId::LineWidth;
+ return true;
+ }
+
+ return false;
+}
+
+bool TryParseTokenReferenceValue(const std::string& text, UIStyleValue& outValue) {
+ const std::string trimmed = TrimAscii(text);
+ if (trimmed.empty()) {
+ return false;
+ }
+
+ outValue = UIStyleValue::Token(trimmed);
+ return true;
+}
+
+bool TryParsePropertyValue(
+ UIStylePropertyId propertyId,
+ const std::string& text,
+ UIStyleValue& outValue) {
+ const std::string trimmed = TrimAscii(text);
+ if (trimmed.empty()) {
+ return false;
+ }
+
+ const UIStyleValueType expectedType = GetExpectedValueType(propertyId);
+ switch (expectedType) {
+ case UIStyleValueType::Color: {
+ Color color = {};
+ if (TryParseColorValue(trimmed, color)) {
+ outValue = UIStyleValue(color);
+ return true;
+ }
+ return TryParseTokenReferenceValue(trimmed, outValue);
+ }
+ case UIStyleValueType::Float: {
+ float value = 0.0f;
+ if (TryParseFloat(trimmed, value)) {
+ outValue = UIStyleValue(value);
+ return true;
+ }
+ return TryParseTokenReferenceValue(trimmed, outValue);
+ }
+ case UIStyleValueType::Thickness: {
+ UIThickness thickness = {};
+ if (TryParseThicknessValue(trimmed, thickness)) {
+ outValue = UIStyleValue(thickness);
+ return true;
+ }
+
+ float uniform = 0.0f;
+ if (TryParseFloat(trimmed, uniform)) {
+ outValue = UIStyleValue(uniform);
+ return true;
+ }
+
+ return TryParseTokenReferenceValue(trimmed, outValue);
+ }
+ case UIStyleValueType::CornerRadius: {
+ UICornerRadius radius = {};
+ if (TryParseCornerRadiusValue(trimmed, radius)) {
+ outValue = UIStyleValue(radius);
+ return true;
+ }
+
+ float uniform = 0.0f;
+ if (TryParseFloat(trimmed, uniform)) {
+ outValue = UIStyleValue(uniform);
+ return true;
+ }
+
+ return TryParseTokenReferenceValue(trimmed, outValue);
+ }
+ default:
+ return false;
+ }
+}
+
+bool TryParseThemeTokenValue(const UIDocumentNode& tokenNode, UIStyleValue& outValue) {
+ const std::string tokenTag = ToLowerAscii(ToStdString(tokenNode.tagName));
+ const std::string valueText = GetAttribute(tokenNode, "value");
+ if (valueText.empty()) {
+ return false;
+ }
+
+ if (tokenTag == "color") {
+ Color color = {};
+ if (TryParseColorValue(valueText, color)) {
+ outValue = UIStyleValue(color);
+ return true;
+ }
+ return TryParseTokenReferenceValue(valueText, outValue);
+ }
+
+ if (tokenTag == "spacing" || tokenTag == "float" || tokenTag == "number") {
+ float value = 0.0f;
+ if (TryParseFloat(valueText, value)) {
+ outValue = UIStyleValue(value);
+ return true;
+ }
+ return TryParseTokenReferenceValue(valueText, outValue);
+ }
+
+ if (tokenTag == "radius") {
+ UICornerRadius radius = {};
+ if (TryParseCornerRadiusValue(valueText, radius)) {
+ outValue = UIStyleValue(radius);
+ return true;
+ }
+
+ float uniform = 0.0f;
+ if (TryParseFloat(valueText, uniform)) {
+ outValue = UIStyleValue(uniform);
+ return true;
+ }
+
+ return TryParseTokenReferenceValue(valueText, outValue);
+ }
+
+ if (tokenTag == "padding" || tokenTag == "thickness") {
+ UIThickness thickness = {};
+ if (TryParseThicknessValue(valueText, thickness)) {
+ outValue = UIStyleValue(thickness);
+ return true;
+ }
+
+ float uniform = 0.0f;
+ if (TryParseFloat(valueText, uniform)) {
+ outValue = UIStyleValue(uniform);
+ return true;
+ }
+
+ return TryParseTokenReferenceValue(valueText, outValue);
+ }
+
+ return false;
+}
+
+UIStyleSet& SelectStyleSetForWidget(
+ const UIDocumentNode& widgetNode,
+ UIStyleSheet& styleSheet) {
+ const std::string styleName = TrimAscii(GetAttribute(widgetNode, "style"));
+ if (!styleName.empty()) {
+ if (ToLowerAscii(styleName) == "default") {
+ return styleSheet.DefaultStyle();
+ }
+ return styleSheet.GetOrCreateNamedStyle(styleName);
+ }
+
+ const std::string typeName = TrimAscii(GetAttribute(widgetNode, "type"));
+ if (typeName.empty()) {
+ return styleSheet.DefaultStyle();
+ }
+
+ const std::string normalized = ToLowerAscii(typeName);
+ if (normalized == "*" || normalized == "default") {
+ return styleSheet.DefaultStyle();
+ }
+
+ return styleSheet.GetOrCreateTypeStyle(typeName);
+}
+
+bool ParseTokensNode(
+ const UIDocumentNode& tokensNode,
+ UIThemeDefinition& outDefinition,
+ std::string& outErrorMessage) {
+ for (const UIDocumentNode& tokenNode : tokensNode.children) {
+ const std::string name = TrimAscii(GetAttribute(tokenNode, "name"));
+ if (name.empty()) {
+ outErrorMessage = "Theme token is missing required 'name' attribute.";
+ return false;
+ }
+
+ UIStyleValue value = {};
+ if (!TryParseThemeTokenValue(tokenNode, value)) {
+ outErrorMessage = "Theme token '" + name + "' has an unsupported value.";
+ return false;
+ }
+
+ outDefinition.SetToken(name, value);
+ }
+
+ return true;
+}
+
+bool ParseWidgetsNode(
+ const UIDocumentNode& widgetsNode,
+ UIStyleSheet& outStyleSheet,
+ std::string& outErrorMessage) {
+ for (const UIDocumentNode& widgetNode : widgetsNode.children) {
+ if (widgetNode.tagName != "Widget") {
+ outErrorMessage = "Theme only supports children.";
+ return false;
+ }
+
+ const std::string styleName = TrimAscii(GetAttribute(widgetNode, "style"));
+ const std::string typeName = TrimAscii(GetAttribute(widgetNode, "type"));
+ if (styleName.empty() && typeName.empty()) {
+ outErrorMessage = "Theme must declare either 'type' or 'style'.";
+ return false;
+ }
+
+ UIStyleSet& styleSet = SelectStyleSetForWidget(widgetNode, outStyleSheet);
+ for (const UIDocumentNode& propertyNode : widgetNode.children) {
+ if (propertyNode.tagName != "Property") {
+ outErrorMessage = "Theme only supports children.";
+ return false;
+ }
+
+ UIStylePropertyId propertyId = UIStylePropertyId::BackgroundColor;
+ const std::string propertyName = GetAttribute(propertyNode, "name");
+ if (!TryMapPropertyName(propertyName, propertyId)) {
+ outErrorMessage = "Theme property '" + propertyName + "' is unsupported.";
+ return false;
+ }
+
+ UIStyleValue value = {};
+ if (!TryParsePropertyValue(propertyId, GetAttribute(propertyNode, "value"), value)) {
+ outErrorMessage = "Theme property '" + propertyName + "' has an unsupported value.";
+ return false;
+ }
+
+ styleSet.SetProperty(propertyId, value);
+ }
+ }
+
+ return true;
+}
+
+} // namespace
+
+UIDocumentStyleCompileResult CompileDocumentStyle(const UIDocumentModel& themeDocument) {
+ UIDocumentStyleCompileResult result = {};
+ result.succeeded = TryCompileDocumentStyle(
+ themeDocument,
+ result.theme,
+ result.styleSheet,
+ &result.errorMessage);
+ return result;
+}
+
+bool TryCompileDocumentStyle(
+ const UIDocumentModel& themeDocument,
+ UITheme& outTheme,
+ UIStyleSheet& outStyleSheet,
+ std::string* outErrorMessage) {
+ outTheme = {};
+ outStyleSheet = {};
+ if (outErrorMessage != nullptr) {
+ outErrorMessage->clear();
+ }
+
+ if (!themeDocument.valid || themeDocument.rootNode.tagName.Empty()) {
+ if (outErrorMessage != nullptr) {
+ *outErrorMessage = "Theme document is invalid.";
+ }
+ return false;
+ }
+
+ if (themeDocument.rootNode.tagName != "Theme") {
+ if (outErrorMessage != nullptr) {
+ *outErrorMessage = "Theme document root tag must be .";
+ }
+ return false;
+ }
+
+ UIThemeDefinition definition = {};
+ definition.name = GetAttribute(themeDocument.rootNode, "name");
+
+ std::string errorMessage = {};
+ for (const UIDocumentNode& childNode : themeDocument.rootNode.children) {
+ if (childNode.tagName == "Tokens") {
+ if (!ParseTokensNode(childNode, definition, errorMessage)) {
+ if (outErrorMessage != nullptr) {
+ *outErrorMessage = errorMessage;
+ }
+ return false;
+ }
+ continue;
+ }
+
+ if (childNode.tagName == "Widgets") {
+ if (!ParseWidgetsNode(childNode, outStyleSheet, errorMessage)) {
+ if (outErrorMessage != nullptr) {
+ *outErrorMessage = errorMessage;
+ }
+ return false;
+ }
+ }
+ }
+
+ outTheme = BuildTheme(definition);
+ return true;
+}
+
+UIStyleSet BuildInlineStyle(const UIDocumentNode& node) {
+ UIStyleSet localStyle = {};
+ for (const UIDocumentAttribute& attribute : node.attributes) {
+ UIStylePropertyId propertyId = UIStylePropertyId::BackgroundColor;
+ if (!TryMapPropertyName(ToStdString(attribute.name), propertyId)) {
+ continue;
+ }
+
+ UIStyleValue value = {};
+ if (!TryParsePropertyValue(propertyId, ToStdString(attribute.value), value)) {
+ continue;
+ }
+
+ localStyle.SetProperty(propertyId, value);
+ }
+
+ return localStyle;
+}
+
+} // namespace Style
+} // namespace UI
+} // namespace XCEngine
diff --git a/engine/src/UI/Style/StyleResolver.cpp b/engine/src/UI/Style/StyleResolver.cpp
index 909c56fc..9b939082 100644
--- a/engine/src/UI/Style/StyleResolver.cpp
+++ b/engine/src/UI/Style/StyleResolver.cpp
@@ -6,6 +6,36 @@ namespace Style {
namespace {
+bool TryCoerceResolvedValue(
+ const UIStyleValue& assignedValue,
+ UIStyleValueType expectedType,
+ UIStyleValue& outValue) {
+ if (!assignedValue.IsSet()) {
+ return false;
+ }
+
+ if (expectedType == UIStyleValueType::None || assignedValue.GetType() == expectedType) {
+ outValue = assignedValue;
+ return true;
+ }
+
+ if (expectedType == UIStyleValueType::Thickness) {
+ if (const float* uniform = assignedValue.TryGetFloat()) {
+ outValue = UIStyleValue(UIThickness::Uniform(*uniform));
+ return true;
+ }
+ }
+
+ if (expectedType == UIStyleValueType::CornerRadius) {
+ if (const float* uniform = assignedValue.TryGetFloat()) {
+ outValue = UIStyleValue(UICornerRadius::Uniform(*uniform));
+ return true;
+ }
+ }
+
+ return false;
+}
+
bool TryResolveAssignedValue(
const UIStyleValue& assignedValue,
UIStyleValueType expectedType,
@@ -20,21 +50,15 @@ bool TryResolveAssignedValue(
return false;
}
- const UITokenResolveResult tokenResult = theme->ResolveToken(tokenReference->name, expectedType);
+ const UITokenResolveResult tokenResult = theme->ResolveToken(tokenReference->name, UIStyleValueType::None);
if (tokenResult.status != UITokenResolveStatus::Resolved) {
return false;
}
- outValue = tokenResult.value;
- return true;
+ return TryCoerceResolvedValue(tokenResult.value, expectedType, outValue);
}
- if (expectedType != UIStyleValueType::None && assignedValue.GetType() != expectedType) {
- return false;
- }
-
- outValue = assignedValue;
- return true;
+ return TryCoerceResolvedValue(assignedValue, expectedType, outValue);
}
const UIStyleSet* GetStyleSetForLayer(UIStyleLayer layer, const UIStyleResolveContext& context) {
diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt
index 66d30a05..d7620ff2 100644
--- a/new_editor/CMakeLists.txt
+++ b/new_editor/CMakeLists.txt
@@ -13,7 +13,10 @@ set(NEW_EDITOR_RESOURCE_FILES
add_library(XCNewEditorLib STATIC
src/editor/EditorShellAsset.cpp
+ src/editor/UIEditorPanelRegistry.cpp
+ src/editor/UIEditorWorkspaceController.cpp
src/editor/UIEditorWorkspaceModel.cpp
+ src/editor/UIEditorWorkspaceSession.cpp
src/Widgets/UIEditorCollectionPrimitives.cpp
)
diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorPanelRegistry.h b/new_editor/include/XCNewEditor/Editor/UIEditorPanelRegistry.h
new file mode 100644
index 00000000..40358f8f
--- /dev/null
+++ b/new_editor/include/XCNewEditor/Editor/UIEditorPanelRegistry.h
@@ -0,0 +1,52 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+
+namespace XCEngine::NewEditor {
+
+enum class UIEditorPanelPresentationKind : std::uint8_t {
+ Placeholder = 0
+};
+
+struct UIEditorPanelDescriptor {
+ std::string panelId = {};
+ std::string defaultTitle = {};
+ UIEditorPanelPresentationKind presentationKind = UIEditorPanelPresentationKind::Placeholder;
+ bool placeholder = true;
+ bool canHide = true;
+ bool canClose = true;
+};
+
+struct UIEditorPanelRegistry {
+ std::vector panels = {};
+};
+
+enum class UIEditorPanelRegistryValidationCode : std::uint8_t {
+ None = 0,
+ EmptyPanelId,
+ EmptyDefaultTitle,
+ DuplicatePanelId
+};
+
+struct UIEditorPanelRegistryValidationResult {
+ UIEditorPanelRegistryValidationCode code = UIEditorPanelRegistryValidationCode::None;
+ std::string message = {};
+
+ [[nodiscard]] bool IsValid() const {
+ return code == UIEditorPanelRegistryValidationCode::None;
+ }
+};
+
+UIEditorPanelRegistry BuildDefaultEditorShellPanelRegistry();
+
+const UIEditorPanelDescriptor* FindUIEditorPanelDescriptor(
+ const UIEditorPanelRegistry& registry,
+ std::string_view panelId);
+
+UIEditorPanelRegistryValidationResult ValidateUIEditorPanelRegistry(
+ const UIEditorPanelRegistry& registry);
+
+} // namespace XCEngine::NewEditor
diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceController.h b/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceController.h
new file mode 100644
index 00000000..6240d890
--- /dev/null
+++ b/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceController.h
@@ -0,0 +1,111 @@
+#pragma once
+
+#include
+
+#include
+#include
+#include
+#include
+
+namespace XCEngine::NewEditor {
+
+enum class UIEditorWorkspaceCommandKind : std::uint8_t {
+ OpenPanel = 0,
+ ClosePanel,
+ ShowPanel,
+ HidePanel,
+ ActivatePanel,
+ ResetWorkspace
+};
+
+enum class UIEditorWorkspaceCommandStatus : std::uint8_t {
+ Changed = 0,
+ NoOp,
+ Rejected
+};
+
+struct UIEditorWorkspaceCommand {
+ UIEditorWorkspaceCommandKind kind = UIEditorWorkspaceCommandKind::ActivatePanel;
+ std::string panelId = {};
+};
+
+struct UIEditorWorkspaceCommandResult {
+ UIEditorWorkspaceCommandKind kind = UIEditorWorkspaceCommandKind::ActivatePanel;
+ UIEditorWorkspaceCommandStatus status = UIEditorWorkspaceCommandStatus::Rejected;
+ std::string panelId = {};
+ std::string message = {};
+ std::string activePanelId = {};
+ std::vector visiblePanelIds = {};
+};
+
+enum class UIEditorWorkspaceControllerValidationCode : std::uint8_t {
+ None = 0,
+ InvalidPanelRegistry,
+ InvalidWorkspace,
+ InvalidWorkspaceSession
+};
+
+struct UIEditorWorkspaceControllerValidationResult {
+ UIEditorWorkspaceControllerValidationCode code =
+ UIEditorWorkspaceControllerValidationCode::None;
+ std::string message = {};
+
+ [[nodiscard]] bool IsValid() const {
+ return code == UIEditorWorkspaceControllerValidationCode::None;
+ }
+};
+
+std::string_view GetUIEditorWorkspaceCommandKindName(UIEditorWorkspaceCommandKind kind);
+std::string_view GetUIEditorWorkspaceCommandStatusName(UIEditorWorkspaceCommandStatus status);
+
+class UIEditorWorkspaceController {
+public:
+ UIEditorWorkspaceController() = default;
+ UIEditorWorkspaceController(
+ UIEditorPanelRegistry panelRegistry,
+ UIEditorWorkspaceModel workspace,
+ UIEditorWorkspaceSession session);
+
+ const UIEditorPanelRegistry& GetPanelRegistry() const {
+ return m_panelRegistry;
+ }
+
+ const UIEditorWorkspaceModel& GetWorkspace() const {
+ return m_workspace;
+ }
+
+ const UIEditorWorkspaceSession& GetSession() const {
+ return m_session;
+ }
+
+ UIEditorWorkspaceControllerValidationResult ValidateState() const;
+ UIEditorWorkspaceCommandResult Dispatch(const UIEditorWorkspaceCommand& command);
+
+private:
+ UIEditorWorkspaceCommandResult BuildResult(
+ const UIEditorWorkspaceCommand& command,
+ UIEditorWorkspaceCommandStatus status,
+ std::string message) const;
+
+ UIEditorWorkspaceCommandResult FinalizeMutation(
+ const UIEditorWorkspaceCommand& command,
+ bool changed,
+ std::string changedMessage,
+ std::string unexpectedFailureMessage,
+ const UIEditorWorkspaceModel& previousWorkspace,
+ const UIEditorWorkspaceSession& previousSession);
+
+ const UIEditorPanelDescriptor* FindPanelDescriptor(std::string_view panelId) const;
+
+ UIEditorPanelRegistry m_panelRegistry = {};
+ UIEditorWorkspaceModel m_baselineWorkspace = {};
+ UIEditorWorkspaceSession m_baselineSession = {};
+ UIEditorWorkspaceModel m_workspace = {};
+ UIEditorWorkspaceSession m_session = {};
+};
+
+UIEditorWorkspaceController BuildDefaultUIEditorWorkspaceController(
+ const UIEditorPanelRegistry& panelRegistry,
+ const UIEditorWorkspaceModel& workspace);
+
+} // namespace XCEngine::NewEditor
diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h b/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h
index 584d9e0a..1bae265f 100644
--- a/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h
+++ b/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h
@@ -70,6 +70,8 @@ struct UIEditorWorkspaceVisiblePanel {
bool placeholder = false;
};
+UIEditorWorkspaceModel BuildDefaultEditorShellWorkspaceModel();
+
UIEditorWorkspaceNode BuildUIEditorWorkspacePanel(
std::string nodeId,
std::string panelId,
diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceSession.h b/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceSession.h
new file mode 100644
index 00000000..bfb42b2d
--- /dev/null
+++ b/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceSession.h
@@ -0,0 +1,94 @@
+#pragma once
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+namespace XCEngine::NewEditor {
+
+struct UIEditorPanelSessionState {
+ std::string panelId = {};
+ bool open = true;
+ bool visible = true;
+};
+
+struct UIEditorWorkspaceSession {
+ std::vector panelStates = {};
+};
+
+enum class UIEditorWorkspaceSessionValidationCode : std::uint8_t {
+ None = 0,
+ MissingPanelState,
+ UnknownPanelId,
+ DuplicatePanelId,
+ ClosedPanelVisible,
+ NonHideablePanelHidden,
+ NonCloseablePanelClosed,
+ InvalidActivePanelId
+};
+
+struct UIEditorWorkspaceSessionValidationResult {
+ UIEditorWorkspaceSessionValidationCode code = UIEditorWorkspaceSessionValidationCode::None;
+ std::string message = {};
+
+ [[nodiscard]] bool IsValid() const {
+ return code == UIEditorWorkspaceSessionValidationCode::None;
+ }
+};
+
+UIEditorWorkspaceSession BuildDefaultUIEditorWorkspaceSession(
+ const UIEditorPanelRegistry& panelRegistry,
+ const UIEditorWorkspaceModel& workspace);
+
+const UIEditorPanelSessionState* FindUIEditorPanelSessionState(
+ const UIEditorWorkspaceSession& session,
+ std::string_view panelId);
+
+UIEditorWorkspaceSessionValidationResult ValidateUIEditorWorkspaceSession(
+ const UIEditorPanelRegistry& panelRegistry,
+ const UIEditorWorkspaceModel& workspace,
+ const UIEditorWorkspaceSession& session);
+
+std::vector CollectUIEditorWorkspaceVisiblePanels(
+ const UIEditorWorkspaceModel& workspace,
+ const UIEditorWorkspaceSession& session);
+
+const UIEditorWorkspacePanelState* FindUIEditorWorkspaceActivePanel(
+ const UIEditorWorkspaceModel& workspace,
+ const UIEditorWorkspaceSession& session);
+
+bool TryOpenUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId);
+
+bool TryCloseUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId);
+
+bool TryShowUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId);
+
+bool TryHideUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId);
+
+bool TryActivateUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId);
+
+} // namespace XCEngine::NewEditor
diff --git a/new_editor/src/Host/Application.cpp b/new_editor/src/Host/Application.cpp
index a4073eeb..e3526abe 100644
--- a/new_editor/src/Host/Application.cpp
+++ b/new_editor/src/Host/Application.cpp
@@ -82,6 +82,17 @@ std::string FormatPoint(const UIPoint& point) {
return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")";
}
+void AppendErrorMessage(std::string& target, const std::string& message) {
+ if (message.empty()) {
+ return;
+ }
+
+ if (!target.empty()) {
+ target += " | ";
+ }
+ target += message;
+}
+
std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) {
switch (wParam) {
case 'A': return static_cast(KeyCode::A);
@@ -392,9 +403,19 @@ bool Application::LoadStructuredScreen(const char* triggerReason) {
m_screenAsset.themePath = m_shellAssetDefinition.themePath.string();
const bool loaded = m_screenPlayer.Load(m_screenAsset);
+ const EditorShellAssetValidationResult shellAssetValidation =
+ ValidateEditorShellAsset(m_shellAssetDefinition);
m_useStructuredScreen = loaded;
m_runtimeStatus = loaded ? "XCUI Editor Shell" : "Editor Shell | Load Error";
- m_runtimeError = loaded ? std::string() : m_screenPlayer.GetLastError();
+ m_runtimeError.clear();
+ if (!loaded) {
+ AppendErrorMessage(m_runtimeError, m_screenPlayer.GetLastError());
+ }
+ if (!shellAssetValidation.IsValid()) {
+ AppendErrorMessage(
+ m_runtimeError,
+ "Editor shell asset invalid: " + shellAssetValidation.message);
+ }
RebuildTrackedFileStates();
return loaded;
}
diff --git a/new_editor/src/editor/EditorShellAsset.cpp b/new_editor/src/editor/EditorShellAsset.cpp
index ee43912a..e293b454 100644
--- a/new_editor/src/editor/EditorShellAsset.cpp
+++ b/new_editor/src/editor/EditorShellAsset.cpp
@@ -1,13 +1,106 @@
#include "EditorShellAsset.h"
+#include
+
namespace XCEngine::NewEditor {
+namespace {
+
+EditorShellAssetValidationResult MakeValidationError(
+ EditorShellAssetValidationCode code,
+ std::string message) {
+ EditorShellAssetValidationResult result = {};
+ result.code = code;
+ result.message = std::move(message);
+ return result;
+}
+
+EditorShellAssetValidationResult ValidateWorkspacePanelsAgainstRegistry(
+ const UIEditorWorkspaceNode& node,
+ const UIEditorPanelRegistry& panelRegistry) {
+ if (node.kind == UIEditorWorkspaceNodeKind::Panel) {
+ const UIEditorPanelDescriptor* descriptor =
+ FindUIEditorPanelDescriptor(panelRegistry, node.panel.panelId);
+ if (descriptor == nullptr) {
+ return MakeValidationError(
+ EditorShellAssetValidationCode::MissingPanelDescriptor,
+ "Workspace panel '" + node.panel.panelId + "' is missing from the panel registry.");
+ }
+
+ if (node.panel.title != descriptor->defaultTitle) {
+ return MakeValidationError(
+ EditorShellAssetValidationCode::PanelTitleMismatch,
+ "Workspace panel '" + node.panel.panelId + "' title does not match the registry default title.");
+ }
+
+ if (node.panel.placeholder != descriptor->placeholder) {
+ return MakeValidationError(
+ EditorShellAssetValidationCode::PanelPlaceholderMismatch,
+ "Workspace panel '" + node.panel.panelId + "' placeholder flag does not match the registry descriptor.");
+ }
+
+ return {};
+ }
+
+ for (const UIEditorWorkspaceNode& child : node.children) {
+ EditorShellAssetValidationResult result =
+ ValidateWorkspacePanelsAgainstRegistry(child, panelRegistry);
+ if (!result.IsValid()) {
+ return result;
+ }
+ }
+
+ return {};
+}
+
+} // namespace
+
EditorShellAsset BuildDefaultEditorShellAsset(const std::filesystem::path& repoRoot) {
EditorShellAsset asset = {};
asset.documentPath = (repoRoot / "new_editor/ui/views/editor_shell.xcui").lexically_normal();
asset.themePath = (repoRoot / "new_editor/ui/themes/editor_shell.xctheme").lexically_normal();
asset.captureRootPath = (repoRoot / "new_editor/captures").lexically_normal();
+ asset.panelRegistry = BuildDefaultEditorShellPanelRegistry();
+ asset.workspace = BuildDefaultEditorShellWorkspaceModel();
+ asset.workspaceSession = BuildDefaultUIEditorWorkspaceSession(asset.panelRegistry, asset.workspace);
return asset;
}
+EditorShellAssetValidationResult ValidateEditorShellAsset(const EditorShellAsset& asset) {
+ const UIEditorPanelRegistryValidationResult registryValidation =
+ ValidateUIEditorPanelRegistry(asset.panelRegistry);
+ if (!registryValidation.IsValid()) {
+ return MakeValidationError(
+ EditorShellAssetValidationCode::InvalidPanelRegistry,
+ registryValidation.message);
+ }
+
+ const UIEditorWorkspaceValidationResult workspaceValidation =
+ ValidateUIEditorWorkspace(asset.workspace);
+ if (!workspaceValidation.IsValid()) {
+ return MakeValidationError(
+ EditorShellAssetValidationCode::InvalidWorkspace,
+ workspaceValidation.message);
+ }
+
+ const EditorShellAssetValidationResult panelRegistryConsistency =
+ ValidateWorkspacePanelsAgainstRegistry(asset.workspace.root, asset.panelRegistry);
+ if (!panelRegistryConsistency.IsValid()) {
+ return panelRegistryConsistency;
+ }
+
+ const UIEditorWorkspaceSessionValidationResult workspaceSessionValidation =
+ ValidateUIEditorWorkspaceSession(
+ asset.panelRegistry,
+ asset.workspace,
+ asset.workspaceSession);
+ if (!workspaceSessionValidation.IsValid()) {
+ return MakeValidationError(
+ EditorShellAssetValidationCode::InvalidWorkspaceSession,
+ workspaceSessionValidation.message);
+ }
+
+ return {};
+}
+
} // namespace XCEngine::NewEditor
diff --git a/new_editor/src/editor/EditorShellAsset.h b/new_editor/src/editor/EditorShellAsset.h
index a0d1f01c..8bca4a03 100644
--- a/new_editor/src/editor/EditorShellAsset.h
+++ b/new_editor/src/editor/EditorShellAsset.h
@@ -1,5 +1,10 @@
#pragma once
+#include
+#include
+#include
+
+#include
#include
#include
@@ -10,8 +15,31 @@ struct EditorShellAsset {
std::filesystem::path documentPath = {};
std::filesystem::path themePath = {};
std::filesystem::path captureRootPath = {};
+ UIEditorPanelRegistry panelRegistry = {};
+ UIEditorWorkspaceModel workspace = {};
+ UIEditorWorkspaceSession workspaceSession = {};
+};
+
+enum class EditorShellAssetValidationCode : std::uint8_t {
+ None = 0,
+ InvalidPanelRegistry,
+ InvalidWorkspace,
+ InvalidWorkspaceSession,
+ MissingPanelDescriptor,
+ PanelTitleMismatch,
+ PanelPlaceholderMismatch
+};
+
+struct EditorShellAssetValidationResult {
+ EditorShellAssetValidationCode code = EditorShellAssetValidationCode::None;
+ std::string message = {};
+
+ [[nodiscard]] bool IsValid() const {
+ return code == EditorShellAssetValidationCode::None;
+ }
};
EditorShellAsset BuildDefaultEditorShellAsset(const std::filesystem::path& repoRoot);
+EditorShellAssetValidationResult ValidateEditorShellAsset(const EditorShellAsset& asset);
} // namespace XCEngine::NewEditor
diff --git a/new_editor/src/editor/UIEditorPanelRegistry.cpp b/new_editor/src/editor/UIEditorPanelRegistry.cpp
new file mode 100644
index 00000000..ffcaac88
--- /dev/null
+++ b/new_editor/src/editor/UIEditorPanelRegistry.cpp
@@ -0,0 +1,74 @@
+#include
+
+#include
+#include
+
+namespace XCEngine::NewEditor {
+
+namespace {
+
+UIEditorPanelRegistryValidationResult MakeValidationError(
+ UIEditorPanelRegistryValidationCode code,
+ std::string message) {
+ UIEditorPanelRegistryValidationResult result = {};
+ result.code = code;
+ result.message = std::move(message);
+ return result;
+}
+
+} // namespace
+
+UIEditorPanelRegistry BuildDefaultEditorShellPanelRegistry() {
+ UIEditorPanelRegistry registry = {};
+ registry.panels = {
+ {
+ "editor-foundation-root",
+ "Root Surface",
+ UIEditorPanelPresentationKind::Placeholder,
+ true,
+ false,
+ false
+ }
+ };
+ return registry;
+}
+
+const UIEditorPanelDescriptor* FindUIEditorPanelDescriptor(
+ const UIEditorPanelRegistry& registry,
+ std::string_view panelId) {
+ for (const UIEditorPanelDescriptor& descriptor : registry.panels) {
+ if (descriptor.panelId == panelId) {
+ return &descriptor;
+ }
+ }
+
+ return nullptr;
+}
+
+UIEditorPanelRegistryValidationResult ValidateUIEditorPanelRegistry(
+ const UIEditorPanelRegistry& registry) {
+ std::unordered_set panelIds = {};
+ for (const UIEditorPanelDescriptor& descriptor : registry.panels) {
+ if (descriptor.panelId.empty()) {
+ return MakeValidationError(
+ UIEditorPanelRegistryValidationCode::EmptyPanelId,
+ "Panel registry entry must define a panelId.");
+ }
+
+ if (descriptor.defaultTitle.empty()) {
+ return MakeValidationError(
+ UIEditorPanelRegistryValidationCode::EmptyDefaultTitle,
+ "Panel descriptor '" + descriptor.panelId + "' must define a defaultTitle.");
+ }
+
+ if (!panelIds.insert(descriptor.panelId).second) {
+ return MakeValidationError(
+ UIEditorPanelRegistryValidationCode::DuplicatePanelId,
+ "Panel descriptor '" + descriptor.panelId + "' is duplicated in the registry.");
+ }
+ }
+
+ return {};
+}
+
+} // namespace XCEngine::NewEditor
diff --git a/new_editor/src/editor/UIEditorWorkspaceController.cpp b/new_editor/src/editor/UIEditorWorkspaceController.cpp
new file mode 100644
index 00000000..0776366c
--- /dev/null
+++ b/new_editor/src/editor/UIEditorWorkspaceController.cpp
@@ -0,0 +1,357 @@
+#include
+
+#include
+
+namespace XCEngine::NewEditor {
+
+namespace {
+
+bool AreWorkspaceNodesEquivalent(
+ const UIEditorWorkspaceNode& lhs,
+ const UIEditorWorkspaceNode& rhs) {
+ if (lhs.kind != rhs.kind ||
+ lhs.nodeId != rhs.nodeId ||
+ lhs.splitAxis != rhs.splitAxis ||
+ lhs.splitRatio != rhs.splitRatio ||
+ lhs.selectedTabIndex != rhs.selectedTabIndex ||
+ lhs.panel.panelId != rhs.panel.panelId ||
+ lhs.panel.title != rhs.panel.title ||
+ lhs.panel.placeholder != rhs.panel.placeholder ||
+ lhs.children.size() != rhs.children.size()) {
+ return false;
+ }
+
+ for (std::size_t index = 0; index < lhs.children.size(); ++index) {
+ if (!AreWorkspaceNodesEquivalent(lhs.children[index], rhs.children[index])) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+bool AreWorkspaceModelsEquivalent(
+ const UIEditorWorkspaceModel& lhs,
+ const UIEditorWorkspaceModel& rhs) {
+ return lhs.activePanelId == rhs.activePanelId &&
+ AreWorkspaceNodesEquivalent(lhs.root, rhs.root);
+}
+
+bool AreWorkspaceSessionsEquivalent(
+ const UIEditorWorkspaceSession& lhs,
+ const UIEditorWorkspaceSession& rhs) {
+ if (lhs.panelStates.size() != rhs.panelStates.size()) {
+ return false;
+ }
+
+ for (std::size_t index = 0; index < lhs.panelStates.size(); ++index) {
+ const UIEditorPanelSessionState& lhsState = lhs.panelStates[index];
+ const UIEditorPanelSessionState& rhsState = rhs.panelStates[index];
+ if (lhsState.panelId != rhsState.panelId ||
+ lhsState.open != rhsState.open ||
+ lhsState.visible != rhsState.visible) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+std::vector CollectVisiblePanelIds(
+ const UIEditorWorkspaceModel& workspace,
+ const UIEditorWorkspaceSession& session) {
+ const std::vector panels =
+ CollectUIEditorWorkspaceVisiblePanels(workspace, session);
+
+ std::vector ids = {};
+ ids.reserve(panels.size());
+ for (const UIEditorWorkspaceVisiblePanel& panel : panels) {
+ ids.push_back(panel.panelId);
+ }
+
+ return ids;
+}
+
+} // namespace
+
+std::string_view GetUIEditorWorkspaceCommandKindName(UIEditorWorkspaceCommandKind kind) {
+ switch (kind) {
+ case UIEditorWorkspaceCommandKind::OpenPanel:
+ return "OpenPanel";
+ case UIEditorWorkspaceCommandKind::ClosePanel:
+ return "ClosePanel";
+ case UIEditorWorkspaceCommandKind::ShowPanel:
+ return "ShowPanel";
+ case UIEditorWorkspaceCommandKind::HidePanel:
+ return "HidePanel";
+ case UIEditorWorkspaceCommandKind::ActivatePanel:
+ return "ActivatePanel";
+ case UIEditorWorkspaceCommandKind::ResetWorkspace:
+ return "ResetWorkspace";
+ }
+
+ return "Unknown";
+}
+
+std::string_view GetUIEditorWorkspaceCommandStatusName(UIEditorWorkspaceCommandStatus status) {
+ switch (status) {
+ case UIEditorWorkspaceCommandStatus::Changed:
+ return "Changed";
+ case UIEditorWorkspaceCommandStatus::NoOp:
+ return "NoOp";
+ case UIEditorWorkspaceCommandStatus::Rejected:
+ return "Rejected";
+ }
+
+ return "Unknown";
+}
+
+UIEditorWorkspaceController::UIEditorWorkspaceController(
+ UIEditorPanelRegistry panelRegistry,
+ UIEditorWorkspaceModel workspace,
+ UIEditorWorkspaceSession session)
+ : m_panelRegistry(std::move(panelRegistry))
+ , m_baselineWorkspace(workspace)
+ , m_baselineSession(session)
+ , m_workspace(std::move(workspace))
+ , m_session(std::move(session)) {
+}
+
+UIEditorWorkspaceControllerValidationResult UIEditorWorkspaceController::ValidateState() const {
+ const UIEditorPanelRegistryValidationResult registryValidation =
+ ValidateUIEditorPanelRegistry(m_panelRegistry);
+ if (!registryValidation.IsValid()) {
+ UIEditorWorkspaceControllerValidationResult result = {};
+ result.code = UIEditorWorkspaceControllerValidationCode::InvalidPanelRegistry;
+ result.message = registryValidation.message;
+ return result;
+ }
+
+ const UIEditorWorkspaceValidationResult workspaceValidation =
+ ValidateUIEditorWorkspace(m_workspace);
+ if (!workspaceValidation.IsValid()) {
+ UIEditorWorkspaceControllerValidationResult result = {};
+ result.code = UIEditorWorkspaceControllerValidationCode::InvalidWorkspace;
+ result.message = workspaceValidation.message;
+ return result;
+ }
+
+ const UIEditorWorkspaceSessionValidationResult sessionValidation =
+ ValidateUIEditorWorkspaceSession(m_panelRegistry, m_workspace, m_session);
+ if (!sessionValidation.IsValid()) {
+ UIEditorWorkspaceControllerValidationResult result = {};
+ result.code = UIEditorWorkspaceControllerValidationCode::InvalidWorkspaceSession;
+ result.message = sessionValidation.message;
+ return result;
+ }
+
+ return {};
+}
+
+UIEditorWorkspaceCommandResult UIEditorWorkspaceController::BuildResult(
+ const UIEditorWorkspaceCommand& command,
+ UIEditorWorkspaceCommandStatus status,
+ std::string message) const {
+ UIEditorWorkspaceCommandResult result = {};
+ result.kind = command.kind;
+ result.status = status;
+ result.panelId = command.panelId;
+ result.message = std::move(message);
+ result.activePanelId = m_workspace.activePanelId;
+ result.visiblePanelIds = CollectVisiblePanelIds(m_workspace, m_session);
+ return result;
+}
+
+UIEditorWorkspaceCommandResult UIEditorWorkspaceController::FinalizeMutation(
+ const UIEditorWorkspaceCommand& command,
+ bool changed,
+ std::string changedMessage,
+ std::string unexpectedFailureMessage,
+ const UIEditorWorkspaceModel& previousWorkspace,
+ const UIEditorWorkspaceSession& previousSession) {
+ if (!changed) {
+ return BuildResult(
+ command,
+ UIEditorWorkspaceCommandStatus::Rejected,
+ std::move(unexpectedFailureMessage));
+ }
+
+ const UIEditorWorkspaceControllerValidationResult validation = ValidateState();
+ if (!validation.IsValid()) {
+ m_workspace = previousWorkspace;
+ m_session = previousSession;
+ return BuildResult(
+ command,
+ UIEditorWorkspaceCommandStatus::Rejected,
+ "Command produced invalid workspace state: " + validation.message);
+ }
+
+ return BuildResult(
+ command,
+ UIEditorWorkspaceCommandStatus::Changed,
+ std::move(changedMessage));
+}
+
+const UIEditorPanelDescriptor* UIEditorWorkspaceController::FindPanelDescriptor(
+ std::string_view panelId) const {
+ return FindUIEditorPanelDescriptor(m_panelRegistry, panelId);
+}
+
+UIEditorWorkspaceCommandResult UIEditorWorkspaceController::Dispatch(
+ const UIEditorWorkspaceCommand& command) {
+ const UIEditorWorkspaceControllerValidationResult validation = ValidateState();
+ if (command.kind != UIEditorWorkspaceCommandKind::ResetWorkspace &&
+ !validation.IsValid()) {
+ return BuildResult(
+ command,
+ UIEditorWorkspaceCommandStatus::Rejected,
+ "Controller state invalid: " + validation.message);
+ }
+
+ const UIEditorWorkspaceModel previousWorkspace = m_workspace;
+ const UIEditorWorkspaceSession previousSession = m_session;
+ const UIEditorPanelSessionState* panelState =
+ command.kind == UIEditorWorkspaceCommandKind::ResetWorkspace
+ ? nullptr
+ : FindUIEditorPanelSessionState(m_session, command.panelId);
+ const UIEditorPanelDescriptor* panelDescriptor =
+ command.kind == UIEditorWorkspaceCommandKind::ResetWorkspace
+ ? nullptr
+ : FindPanelDescriptor(command.panelId);
+
+ switch (command.kind) {
+ case UIEditorWorkspaceCommandKind::OpenPanel:
+ if (command.panelId.empty()) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "OpenPanel requires a panelId.");
+ }
+ if (panelDescriptor == nullptr || panelState == nullptr) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "OpenPanel target panel is missing.");
+ }
+ if (panelState->open && panelState->visible) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already open and visible.");
+ }
+ return FinalizeMutation(
+ command,
+ TryOpenUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
+ "Panel opened and activated.",
+ "OpenPanel failed unexpectedly.",
+ previousWorkspace,
+ previousSession);
+
+ case UIEditorWorkspaceCommandKind::ClosePanel:
+ if (command.panelId.empty()) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ClosePanel requires a panelId.");
+ }
+ if (panelDescriptor == nullptr || panelState == nullptr) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ClosePanel target panel is missing.");
+ }
+ if (!panelDescriptor->canClose) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Panel cannot be closed.");
+ }
+ if (!panelState->open) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already closed.");
+ }
+ return FinalizeMutation(
+ command,
+ TryCloseUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
+ "Panel closed.",
+ "ClosePanel failed unexpectedly.",
+ previousWorkspace,
+ previousSession);
+
+ case UIEditorWorkspaceCommandKind::ShowPanel:
+ if (command.panelId.empty()) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ShowPanel requires a panelId.");
+ }
+ if (panelDescriptor == nullptr || panelState == nullptr) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ShowPanel target panel is missing.");
+ }
+ if (!panelState->open) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Closed panel must be opened before it can be shown.");
+ }
+ if (panelState->visible) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already visible.");
+ }
+ return FinalizeMutation(
+ command,
+ TryShowUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
+ "Panel shown and activated.",
+ "ShowPanel failed unexpectedly.",
+ previousWorkspace,
+ previousSession);
+
+ case UIEditorWorkspaceCommandKind::HidePanel:
+ if (command.panelId.empty()) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "HidePanel requires a panelId.");
+ }
+ if (panelDescriptor == nullptr || panelState == nullptr) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "HidePanel target panel is missing.");
+ }
+ if (!panelDescriptor->canHide) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Panel cannot be hidden.");
+ }
+ if (!panelState->open) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Closed panel cannot be hidden.");
+ }
+ if (!panelState->visible) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already hidden.");
+ }
+ return FinalizeMutation(
+ command,
+ TryHideUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
+ "Panel hidden and active panel re-resolved.",
+ "HidePanel failed unexpectedly.",
+ previousWorkspace,
+ previousSession);
+
+ case UIEditorWorkspaceCommandKind::ActivatePanel:
+ if (command.panelId.empty()) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ActivatePanel requires a panelId.");
+ }
+ if (panelDescriptor == nullptr || panelState == nullptr) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "ActivatePanel target panel is missing.");
+ }
+ if (!panelState->open || !panelState->visible) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Only open and visible panels can be activated.");
+ }
+ if (m_workspace.activePanelId == command.panelId) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Panel is already active.");
+ }
+ return FinalizeMutation(
+ command,
+ TryActivateUIEditorWorkspacePanel(m_panelRegistry, m_workspace, m_session, command.panelId),
+ "Panel activated.",
+ "ActivatePanel failed unexpectedly.",
+ previousWorkspace,
+ previousSession);
+
+ case UIEditorWorkspaceCommandKind::ResetWorkspace:
+ if (AreWorkspaceModelsEquivalent(m_workspace, m_baselineWorkspace) &&
+ AreWorkspaceSessionsEquivalent(m_session, m_baselineSession)) {
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::NoOp, "Workspace already matches the baseline state.");
+ }
+
+ m_workspace = m_baselineWorkspace;
+ m_session = m_baselineSession;
+ return FinalizeMutation(
+ command,
+ true,
+ "Workspace reset to baseline.",
+ "ResetWorkspace failed unexpectedly.",
+ previousWorkspace,
+ previousSession);
+ }
+
+ return BuildResult(command, UIEditorWorkspaceCommandStatus::Rejected, "Unknown command kind.");
+}
+
+UIEditorWorkspaceController BuildDefaultUIEditorWorkspaceController(
+ const UIEditorPanelRegistry& panelRegistry,
+ const UIEditorWorkspaceModel& workspace) {
+ return UIEditorWorkspaceController(
+ panelRegistry,
+ workspace,
+ BuildDefaultUIEditorWorkspaceSession(panelRegistry, workspace));
+}
+
+} // namespace XCEngine::NewEditor
diff --git a/new_editor/src/editor/UIEditorWorkspaceModel.cpp b/new_editor/src/editor/UIEditorWorkspaceModel.cpp
index 771ec5c5..127cffc5 100644
--- a/new_editor/src/editor/UIEditorWorkspaceModel.cpp
+++ b/new_editor/src/editor/UIEditorWorkspaceModel.cpp
@@ -1,3 +1,4 @@
+#include
#include
#include
@@ -21,6 +22,18 @@ bool IsValidSplitRatio(float value) {
return std::isfinite(value) && value > 0.0f && value < 1.0f;
}
+const UIEditorPanelDescriptor& RequirePanelDescriptor(
+ const UIEditorPanelRegistry& registry,
+ std::string_view panelId) {
+ if (const UIEditorPanelDescriptor* descriptor = FindUIEditorPanelDescriptor(registry, panelId);
+ descriptor != nullptr) {
+ return *descriptor;
+ }
+
+ static const UIEditorPanelDescriptor fallbackDescriptor = {};
+ return fallbackDescriptor;
+}
+
const UIEditorWorkspacePanelState* FindPanelRecursive(
const UIEditorWorkspaceNode& node,
std::string_view panelId) {
@@ -192,6 +205,21 @@ UIEditorWorkspaceValidationResult ValidateNodeRecursive(
} // namespace
+UIEditorWorkspaceModel BuildDefaultEditorShellWorkspaceModel() {
+ const UIEditorPanelRegistry registry = BuildDefaultEditorShellPanelRegistry();
+ const UIEditorPanelDescriptor& rootPanel =
+ RequirePanelDescriptor(registry, "editor-foundation-root");
+
+ UIEditorWorkspaceModel workspace = {};
+ workspace.root = BuildUIEditorWorkspacePanel(
+ "editor-foundation-root-node",
+ rootPanel.panelId,
+ rootPanel.defaultTitle,
+ rootPanel.placeholder);
+ workspace.activePanelId = rootPanel.panelId;
+ return workspace;
+}
+
UIEditorWorkspaceNode BuildUIEditorWorkspacePanel(
std::string nodeId,
std::string panelId,
diff --git a/new_editor/src/editor/UIEditorWorkspaceSession.cpp b/new_editor/src/editor/UIEditorWorkspaceSession.cpp
new file mode 100644
index 00000000..17e21544
--- /dev/null
+++ b/new_editor/src/editor/UIEditorWorkspaceSession.cpp
@@ -0,0 +1,476 @@
+#include
+
+#include
+#include
+
+namespace XCEngine::NewEditor {
+
+namespace {
+
+UIEditorWorkspaceSessionValidationResult MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode code,
+ std::string message) {
+ UIEditorWorkspaceSessionValidationResult result = {};
+ result.code = code;
+ result.message = std::move(message);
+ return result;
+}
+
+UIEditorPanelSessionState* FindMutablePanelSessionState(
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ for (UIEditorPanelSessionState& state : session.panelStates) {
+ if (state.panelId == panelId) {
+ return &state;
+ }
+ }
+
+ return nullptr;
+}
+
+const UIEditorPanelDescriptor* FindPanelDescriptor(
+ const UIEditorPanelRegistry& panelRegistry,
+ std::string_view panelId) {
+ return FindUIEditorPanelDescriptor(panelRegistry, panelId);
+}
+
+const UIEditorWorkspacePanelState* FindPanelRecursive(
+ const UIEditorWorkspaceNode& node,
+ std::string_view panelId) {
+ if (node.kind == UIEditorWorkspaceNodeKind::Panel) {
+ return node.panel.panelId == panelId ? &node.panel : nullptr;
+ }
+
+ for (const UIEditorWorkspaceNode& child : node.children) {
+ if (const UIEditorWorkspacePanelState* found = FindPanelRecursive(child, panelId)) {
+ return found;
+ }
+ }
+
+ return nullptr;
+}
+
+void CollectWorkspacePanelIdsRecursive(
+ const UIEditorWorkspaceNode& node,
+ std::vector& outPanelIds) {
+ if (node.kind == UIEditorWorkspaceNodeKind::Panel) {
+ outPanelIds.push_back(node.panel.panelId);
+ return;
+ }
+
+ for (const UIEditorWorkspaceNode& child : node.children) {
+ CollectWorkspacePanelIdsRecursive(child, outPanelIds);
+ }
+}
+
+bool IsPanelOpenAndVisible(
+ const UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ const UIEditorPanelSessionState* state = FindUIEditorPanelSessionState(session, panelId);
+ return state != nullptr && state->open && state->visible;
+}
+
+bool IsPanelSelectable(
+ const UIEditorWorkspaceModel& workspace,
+ const UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ return !panelId.empty() &&
+ IsPanelOpenAndVisible(session, panelId) &&
+ ContainsUIEditorWorkspacePanel(workspace, panelId);
+}
+
+std::size_t ResolveVisibleTabIndex(
+ const UIEditorWorkspaceNode& node,
+ const UIEditorWorkspaceSession& session) {
+ if (node.kind != UIEditorWorkspaceNodeKind::TabStack || node.children.empty()) {
+ return node.selectedTabIndex;
+ }
+
+ if (node.selectedTabIndex < node.children.size()) {
+ const UIEditorWorkspaceNode& selectedChild = node.children[node.selectedTabIndex];
+ if (selectedChild.kind == UIEditorWorkspaceNodeKind::Panel &&
+ IsPanelOpenAndVisible(session, selectedChild.panel.panelId)) {
+ return node.selectedTabIndex;
+ }
+ }
+
+ for (std::size_t index = 0; index < node.children.size(); ++index) {
+ const UIEditorWorkspaceNode& child = node.children[index];
+ if (child.kind == UIEditorWorkspaceNodeKind::Panel &&
+ IsPanelOpenAndVisible(session, child.panel.panelId)) {
+ return index;
+ }
+ }
+
+ return node.children.size();
+}
+
+void CollectVisiblePanelsRecursive(
+ const UIEditorWorkspaceNode& node,
+ const UIEditorWorkspaceSession& session,
+ std::string_view activePanelId,
+ std::vector& outPanels) {
+ switch (node.kind) {
+ case UIEditorWorkspaceNodeKind::Panel: {
+ if (!IsPanelOpenAndVisible(session, node.panel.panelId)) {
+ return;
+ }
+
+ UIEditorWorkspaceVisiblePanel panel = {};
+ panel.panelId = node.panel.panelId;
+ panel.title = node.panel.title;
+ panel.active = node.panel.panelId == activePanelId;
+ panel.placeholder = node.panel.placeholder;
+ outPanels.push_back(std::move(panel));
+ return;
+ }
+
+ case UIEditorWorkspaceNodeKind::TabStack: {
+ const std::size_t resolvedIndex = ResolveVisibleTabIndex(node, session);
+ if (resolvedIndex < node.children.size()) {
+ CollectVisiblePanelsRecursive(
+ node.children[resolvedIndex],
+ session,
+ activePanelId,
+ outPanels);
+ }
+ return;
+ }
+
+ case UIEditorWorkspaceNodeKind::Split:
+ for (const UIEditorWorkspaceNode& child : node.children) {
+ CollectVisiblePanelsRecursive(child, session, activePanelId, outPanels);
+ }
+ return;
+ }
+}
+
+void NormalizeSessionStatesAgainstRegistry(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceSession& session) {
+ for (UIEditorPanelSessionState& state : session.panelStates) {
+ if (!state.open) {
+ state.visible = false;
+ }
+
+ const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, state.panelId);
+ if (descriptor == nullptr) {
+ continue;
+ }
+
+ if (!descriptor->canClose) {
+ state.open = true;
+ state.visible = true;
+ continue;
+ }
+
+ if (!descriptor->canHide && state.open) {
+ state.visible = true;
+ }
+ }
+}
+
+void NormalizeWorkspaceSession(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view preferredActivePanelId) {
+ NormalizeSessionStatesAgainstRegistry(panelRegistry, session);
+
+ std::string targetActivePanelId = {};
+ if (IsPanelSelectable(workspace, session, preferredActivePanelId)) {
+ targetActivePanelId = std::string(preferredActivePanelId);
+ } else if (IsPanelSelectable(workspace, session, workspace.activePanelId)) {
+ targetActivePanelId = workspace.activePanelId;
+ } else {
+ const std::vector visiblePanels =
+ CollectUIEditorWorkspaceVisiblePanels(workspace, session);
+ if (!visiblePanels.empty()) {
+ targetActivePanelId = visiblePanels.front().panelId;
+ }
+ }
+
+ if (targetActivePanelId.empty()) {
+ workspace.activePanelId.clear();
+ return;
+ }
+
+ TryActivateUIEditorWorkspacePanel(workspace, targetActivePanelId);
+}
+
+bool AreWorkspaceNodesEquivalent(
+ const UIEditorWorkspaceNode& lhs,
+ const UIEditorWorkspaceNode& rhs) {
+ if (lhs.kind != rhs.kind ||
+ lhs.nodeId != rhs.nodeId ||
+ lhs.splitAxis != rhs.splitAxis ||
+ lhs.splitRatio != rhs.splitRatio ||
+ lhs.selectedTabIndex != rhs.selectedTabIndex ||
+ lhs.panel.panelId != rhs.panel.panelId ||
+ lhs.panel.title != rhs.panel.title ||
+ lhs.panel.placeholder != rhs.panel.placeholder ||
+ lhs.children.size() != rhs.children.size()) {
+ return false;
+ }
+
+ for (std::size_t index = 0; index < lhs.children.size(); ++index) {
+ if (!AreWorkspaceNodesEquivalent(lhs.children[index], rhs.children[index])) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+bool AreWorkspaceModelsEquivalent(
+ const UIEditorWorkspaceModel& lhs,
+ const UIEditorWorkspaceModel& rhs) {
+ return lhs.activePanelId == rhs.activePanelId &&
+ AreWorkspaceNodesEquivalent(lhs.root, rhs.root);
+}
+
+bool AreWorkspaceSessionsEquivalent(
+ const UIEditorWorkspaceSession& lhs,
+ const UIEditorWorkspaceSession& rhs) {
+ if (lhs.panelStates.size() != rhs.panelStates.size()) {
+ return false;
+ }
+
+ for (std::size_t index = 0; index < lhs.panelStates.size(); ++index) {
+ const UIEditorPanelSessionState& lhsState = lhs.panelStates[index];
+ const UIEditorPanelSessionState& rhsState = rhs.panelStates[index];
+ if (lhsState.panelId != rhsState.panelId ||
+ lhsState.open != rhsState.open ||
+ lhsState.visible != rhsState.visible) {
+ return false;
+ }
+ }
+
+ return true;
+}
+
+} // namespace
+
+UIEditorWorkspaceSession BuildDefaultUIEditorWorkspaceSession(
+ const UIEditorPanelRegistry& panelRegistry,
+ const UIEditorWorkspaceModel& workspace) {
+ UIEditorWorkspaceSession session = {};
+ std::vector panelIds = {};
+ CollectWorkspacePanelIdsRecursive(workspace.root, panelIds);
+ session.panelStates.reserve(panelIds.size());
+ for (std::string& panelId : panelIds) {
+ UIEditorPanelSessionState state = {};
+ state.panelId = std::move(panelId);
+ session.panelStates.push_back(std::move(state));
+ }
+
+ NormalizeSessionStatesAgainstRegistry(panelRegistry, session);
+ return session;
+}
+
+const UIEditorPanelSessionState* FindUIEditorPanelSessionState(
+ const UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ for (const UIEditorPanelSessionState& state : session.panelStates) {
+ if (state.panelId == panelId) {
+ return &state;
+ }
+ }
+
+ return nullptr;
+}
+
+UIEditorWorkspaceSessionValidationResult ValidateUIEditorWorkspaceSession(
+ const UIEditorPanelRegistry& panelRegistry,
+ const UIEditorWorkspaceModel& workspace,
+ const UIEditorWorkspaceSession& session) {
+ std::vector workspacePanelIds = {};
+ CollectWorkspacePanelIdsRecursive(workspace.root, workspacePanelIds);
+
+ std::unordered_set expectedPanelIds = {};
+ expectedPanelIds.insert(workspacePanelIds.begin(), workspacePanelIds.end());
+
+ std::unordered_set seenPanelIds = {};
+ for (const UIEditorPanelSessionState& state : session.panelStates) {
+ if (!seenPanelIds.insert(state.panelId).second) {
+ return MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode::DuplicatePanelId,
+ "Workspace session contains duplicated panel state '" + state.panelId + "'.");
+ }
+
+ if (!expectedPanelIds.contains(state.panelId)) {
+ return MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode::UnknownPanelId,
+ "Workspace session state '" + state.panelId + "' is not present in the workspace tree.");
+ }
+
+ if (!state.open && state.visible) {
+ return MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode::ClosedPanelVisible,
+ "Workspace session state '" + state.panelId + "' cannot be visible while closed.");
+ }
+
+ const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, state.panelId);
+ if (descriptor == nullptr) {
+ return MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode::UnknownPanelId,
+ "Workspace session state '" + state.panelId + "' is missing from the panel registry.");
+ }
+
+ if (!descriptor->canClose && !state.open) {
+ return MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode::NonCloseablePanelClosed,
+ "Workspace session state '" + state.panelId + "' cannot be closed.");
+ }
+
+ if (!descriptor->canHide && state.open && !state.visible) {
+ return MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode::NonHideablePanelHidden,
+ "Workspace session state '" + state.panelId + "' cannot be hidden.");
+ }
+ }
+
+ for (const std::string& panelId : workspacePanelIds) {
+ if (!seenPanelIds.contains(panelId)) {
+ return MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode::MissingPanelState,
+ "Workspace panel '" + panelId + "' is missing from the workspace session.");
+ }
+ }
+
+ if (!workspace.activePanelId.empty() &&
+ FindUIEditorWorkspaceActivePanel(workspace, session) == nullptr) {
+ return MakeValidationError(
+ UIEditorWorkspaceSessionValidationCode::InvalidActivePanelId,
+ "Active panel id '" + workspace.activePanelId + "' is missing, closed, or hidden.");
+ }
+
+ return {};
+}
+
+std::vector CollectUIEditorWorkspaceVisiblePanels(
+ const UIEditorWorkspaceModel& workspace,
+ const UIEditorWorkspaceSession& session) {
+ std::vector visiblePanels = {};
+ CollectVisiblePanelsRecursive(workspace.root, session, workspace.activePanelId, visiblePanels);
+ return visiblePanels;
+}
+
+const UIEditorWorkspacePanelState* FindUIEditorWorkspaceActivePanel(
+ const UIEditorWorkspaceModel& workspace,
+ const UIEditorWorkspaceSession& session) {
+ if (workspace.activePanelId.empty() ||
+ !IsPanelOpenAndVisible(session, workspace.activePanelId)) {
+ return nullptr;
+ }
+
+ const std::vector visiblePanels =
+ CollectUIEditorWorkspaceVisiblePanels(workspace, session);
+ for (const UIEditorWorkspaceVisiblePanel& panel : visiblePanels) {
+ if (panel.panelId == workspace.activePanelId) {
+ return FindPanelRecursive(workspace.root, workspace.activePanelId);
+ }
+ }
+
+ return nullptr;
+}
+
+bool TryOpenUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ const UIEditorWorkspaceModel workspaceBefore = workspace;
+ const UIEditorWorkspaceSession sessionBefore = session;
+
+ UIEditorPanelSessionState* state = FindMutablePanelSessionState(session, panelId);
+ if (state == nullptr) {
+ return false;
+ }
+
+ state->open = true;
+ state->visible = true;
+ NormalizeWorkspaceSession(panelRegistry, workspace, session, panelId);
+ return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
+ !AreWorkspaceSessionsEquivalent(sessionBefore, session);
+}
+
+bool TryCloseUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ const UIEditorWorkspaceModel workspaceBefore = workspace;
+ const UIEditorWorkspaceSession sessionBefore = session;
+
+ UIEditorPanelSessionState* state = FindMutablePanelSessionState(session, panelId);
+ const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, panelId);
+ if (state == nullptr || descriptor == nullptr || !descriptor->canClose) {
+ return false;
+ }
+
+ state->open = false;
+ state->visible = false;
+ NormalizeWorkspaceSession(panelRegistry, workspace, session, {});
+ return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
+ !AreWorkspaceSessionsEquivalent(sessionBefore, session);
+}
+
+bool TryShowUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ const UIEditorWorkspaceModel workspaceBefore = workspace;
+ const UIEditorWorkspaceSession sessionBefore = session;
+
+ UIEditorPanelSessionState* state = FindMutablePanelSessionState(session, panelId);
+ const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, panelId);
+ if (state == nullptr || descriptor == nullptr || !state->open || !descriptor->canHide) {
+ return false;
+ }
+
+ state->visible = true;
+ NormalizeWorkspaceSession(panelRegistry, workspace, session, panelId);
+ return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
+ !AreWorkspaceSessionsEquivalent(sessionBefore, session);
+}
+
+bool TryHideUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ const UIEditorWorkspaceModel workspaceBefore = workspace;
+ const UIEditorWorkspaceSession sessionBefore = session;
+
+ UIEditorPanelSessionState* state = FindMutablePanelSessionState(session, panelId);
+ const UIEditorPanelDescriptor* descriptor = FindPanelDescriptor(panelRegistry, panelId);
+ if (state == nullptr || descriptor == nullptr || !state->open || !descriptor->canHide) {
+ return false;
+ }
+
+ state->visible = false;
+ NormalizeWorkspaceSession(panelRegistry, workspace, session, {});
+ return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
+ !AreWorkspaceSessionsEquivalent(sessionBefore, session);
+}
+
+bool TryActivateUIEditorWorkspacePanel(
+ const UIEditorPanelRegistry& panelRegistry,
+ UIEditorWorkspaceModel& workspace,
+ UIEditorWorkspaceSession& session,
+ std::string_view panelId) {
+ const UIEditorWorkspaceModel workspaceBefore = workspace;
+ const UIEditorWorkspaceSession sessionBefore = session;
+
+ if (!IsPanelSelectable(workspace, session, panelId)) {
+ return false;
+ }
+
+ NormalizeWorkspaceSession(panelRegistry, workspace, session, panelId);
+ return !AreWorkspaceModelsEquivalent(workspaceBefore, workspace) ||
+ !AreWorkspaceSessionsEquivalent(sessionBefore, session);
+}
+
+} // namespace XCEngine::NewEditor
diff --git a/new_editor/ui/themes/editor_shell.xctheme b/new_editor/ui/themes/editor_shell.xctheme
index 710baa93..2aa8452c 100644
--- a/new_editor/ui/themes/editor_shell.xctheme
+++ b/new_editor/ui/themes/editor_shell.xctheme
@@ -1,15 +1,7 @@
-
-
-
-
-
-
-
-
-
-
+
+
@@ -17,16 +9,5 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/new_editor/ui/views/editor_shell.xcui b/new_editor/ui/views/editor_shell.xcui
index 8e181a3f..f800e26d 100644
--- a/new_editor/ui/views/editor_shell.xcui
+++ b/new_editor/ui/views/editor_shell.xcui
@@ -1,8 +1,9 @@
+ theme="../themes/editor_shell.xctheme"
+ style="EditorWorkspace">
diff --git a/tests/UI/Core/CMakeLists.txt b/tests/UI/Core/CMakeLists.txt
index 9d64080a..3651e8de 100644
--- a/tests/UI/Core/CMakeLists.txt
+++ b/tests/UI/Core/CMakeLists.txt
@@ -2,8 +2,8 @@ cmake_minimum_required(VERSION 3.15)
project(XCEngine_CoreUITests)
-add_subdirectory(unit)
add_subdirectory(integration)
+add_subdirectory(unit)
add_custom_target(core_ui_unit_tests
DEPENDS
diff --git a/tests/UI/Core/integration/CMakeLists.txt b/tests/UI/Core/integration/CMakeLists.txt
index bb5c49f2..1f598eb2 100644
--- a/tests/UI/Core/integration/CMakeLists.txt
+++ b/tests/UI/Core/integration/CMakeLists.txt
@@ -1 +1,13 @@
-add_custom_target(core_ui_integration_tests)
+add_subdirectory(shared)
+add_subdirectory(input)
+add_subdirectory(layout)
+add_subdirectory(style)
+add_subdirectory(text)
+
+add_custom_target(core_ui_integration_tests
+ DEPENDS
+ core_ui_input_integration_tests
+ core_ui_layout_integration_tests
+ core_ui_style_integration_tests
+ core_ui_text_integration_tests
+)
diff --git a/tests/UI/Core/integration/README.md b/tests/UI/Core/integration/README.md
index fcaf11ab..d6e1db21 100644
--- a/tests/UI/Core/integration/README.md
+++ b/tests/UI/Core/integration/README.md
@@ -1,8 +1,24 @@
-# Core UI Integration Notes
+# Core UI Integration Validation
-The core XCUI lane currently validates shared primitives through automated unit tests.
+This directory contains the manual XCUI validation system for shared Core primitives.
-Interactive validation belongs to:
+Structure:
-- `tests/UI/Runtime/integration/` for game/runtime UI
-- `tests/UI/Editor/integration/` for editor UI
+- `shared/`: shared host, native renderer, screenshot helper, scenario registry
+- `input/`: shared input validation category
+- `layout/`: shared layout validation category
+- `style/`: shared theme token and style resolution validation category
+- `text/`: shared UTF-8 text rendering and textInput focus marker validation category
+
+Rules:
+
+- One scenario directory maps to one executable.
+- Do not accumulate unrelated checks into one monolithic app.
+- Shared infrastructure belongs in `shared/`, not duplicated per scenario.
+- Screenshots are stored per scenario inside that scenario's `captures/` folder.
+
+Build:
+
+```bash
+cmake --build build --config Debug --target core_ui_integration_tests
+```
diff --git a/tests/UI/Core/integration/input/CMakeLists.txt b/tests/UI/Core/integration/input/CMakeLists.txt
new file mode 100644
index 00000000..8005c612
--- /dev/null
+++ b/tests/UI/Core/integration/input/CMakeLists.txt
@@ -0,0 +1,12 @@
+add_subdirectory(keyboard_focus)
+add_subdirectory(pointer_states)
+add_subdirectory(scroll_view)
+add_subdirectory(shortcut_scope)
+
+add_custom_target(core_ui_input_integration_tests
+ DEPENDS
+ core_ui_input_keyboard_focus_validation
+ core_ui_input_pointer_states_validation
+ core_ui_input_scroll_view_validation
+ core_ui_input_shortcut_scope_validation
+)
diff --git a/tests/UI/Core/integration/input/README.md b/tests/UI/Core/integration/input/README.md
new file mode 100644
index 00000000..a875a14f
--- /dev/null
+++ b/tests/UI/Core/integration/input/README.md
@@ -0,0 +1,8 @@
+# Core Input Integration
+
+这个分类只放共享 XCUI 输入能力的手工验证场景。
+
+规则:
+- 一个场景目录对应一个独立 exe
+- 共享宿主层只放在 `integration/shared/`
+- 不允许把多个无关检查点塞进同一个 exe
\ No newline at end of file
diff --git a/tests/UI/Core/integration/input/keyboard_focus/CMakeLists.txt b/tests/UI/Core/integration/input/keyboard_focus/CMakeLists.txt
new file mode 100644
index 00000000..620c4d3a
--- /dev/null
+++ b/tests/UI/Core/integration/input/keyboard_focus/CMakeLists.txt
@@ -0,0 +1,35 @@
+set(CORE_UI_INPUT_KEYBOARD_FOCUS_RESOURCES
+ View.xcui
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
+)
+
+add_executable(core_ui_input_keyboard_focus_validation WIN32
+ main.cpp
+ ${CORE_UI_INPUT_KEYBOARD_FOCUS_RESOURCES}
+)
+
+target_include_directories(core_ui_input_keyboard_focus_validation PRIVATE
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_input_keyboard_focus_validation PRIVATE
+ UNICODE
+ _UNICODE
+)
+
+if(MSVC)
+ target_compile_options(core_ui_input_keyboard_focus_validation PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_input_keyboard_focus_validation PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_input_keyboard_focus_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_input_keyboard_focus_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreInputKeyboardFocusValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Core/integration/input/keyboard_focus/README.md b/tests/UI/Core/integration/input/keyboard_focus/README.md
new file mode 100644
index 00000000..66392c14
--- /dev/null
+++ b/tests/UI/Core/integration/input/keyboard_focus/README.md
@@ -0,0 +1,15 @@
+# Keyboard Focus Validation
+
+可执行 target:
+- `core_ui_input_keyboard_focus_validation`
+
+运行:
+```bash
+build\tests\UI\Core\integration\input\keyboard_focus\Debug\XCUICoreInputKeyboardFocusValidation.exe
+```
+
+检查点:
+1. 按 `Tab`,焦点依次切换三个按钮。
+2. 按 `Shift+Tab`,焦点反向切换。
+3. 按 `Enter` 或 `Space`,当前 `focus` 按钮进入 `active`。
+4. 松开按键后,`active` 清空。
\ No newline at end of file
diff --git a/tests/UI/Editor/integration/input/keyboard_focus/View.xcui b/tests/UI/Core/integration/input/keyboard_focus/View.xcui
similarity index 79%
rename from tests/UI/Editor/integration/input/keyboard_focus/View.xcui
rename to tests/UI/Core/integration/input/keyboard_focus/View.xcui
index 5145e0a6..7407b8c4 100644
--- a/tests/UI/Editor/integration/input/keyboard_focus/View.xcui
+++ b/tests/UI/Core/integration/input/keyboard_focus/View.xcui
@@ -1,15 +1,15 @@
+ name="CoreInputKeyboardFocus"
+ theme="../../shared/themes/core_validation.xctheme">
-
-
+
+
diff --git a/tests/UI/Editor/integration/input/shortcut_scope/main.cpp b/tests/UI/Core/integration/input/keyboard_focus/main.cpp
similarity index 57%
rename from tests/UI/Editor/integration/input/shortcut_scope/main.cpp
rename to tests/UI/Core/integration/input/keyboard_focus/main.cpp
index 750ced63..58d4e719 100644
--- a/tests/UI/Editor/integration/input/shortcut_scope/main.cpp
+++ b/tests/UI/Core/integration/input/keyboard_focus/main.cpp
@@ -1,8 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
- return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
+ return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
- "editor.input.shortcut_scope");
+ "core.input.keyboard_focus");
}
diff --git a/tests/UI/Core/integration/input/pointer_states/CMakeLists.txt b/tests/UI/Core/integration/input/pointer_states/CMakeLists.txt
new file mode 100644
index 00000000..e36f47ee
--- /dev/null
+++ b/tests/UI/Core/integration/input/pointer_states/CMakeLists.txt
@@ -0,0 +1,35 @@
+set(CORE_UI_INPUT_POINTER_STATES_RESOURCES
+ View.xcui
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
+)
+
+add_executable(core_ui_input_pointer_states_validation WIN32
+ main.cpp
+ ${CORE_UI_INPUT_POINTER_STATES_RESOURCES}
+)
+
+target_include_directories(core_ui_input_pointer_states_validation PRIVATE
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_input_pointer_states_validation PRIVATE
+ UNICODE
+ _UNICODE
+)
+
+if(MSVC)
+ target_compile_options(core_ui_input_pointer_states_validation PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_input_pointer_states_validation PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_input_pointer_states_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_input_pointer_states_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreInputPointerStatesValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Core/integration/input/pointer_states/README.md b/tests/UI/Core/integration/input/pointer_states/README.md
new file mode 100644
index 00000000..34fdff81
--- /dev/null
+++ b/tests/UI/Core/integration/input/pointer_states/README.md
@@ -0,0 +1,14 @@
+# Pointer States Validation
+
+可执行 target:
+- `core_ui_input_pointer_states_validation`
+
+运行:
+```bash
+build\tests\UI\Core\integration\input\pointer_states\Debug\XCUICoreInputPointerStatesValidation.exe
+```
+
+检查点:
+1. hover 左侧按钮,只应变化 `hover`。
+2. 按住中间按钮,应看到 `focus`、`active`、`capture`。
+3. 拖到右侧再松开,应看到 `capture` 清空,route 转到新的目标。
\ No newline at end of file
diff --git a/tests/UI/Editor/integration/input/pointer_states/View.xcui b/tests/UI/Core/integration/input/pointer_states/View.xcui
similarity index 82%
rename from tests/UI/Editor/integration/input/pointer_states/View.xcui
rename to tests/UI/Core/integration/input/pointer_states/View.xcui
index 40fdfe0c..58d8d4aa 100644
--- a/tests/UI/Editor/integration/input/pointer_states/View.xcui
+++ b/tests/UI/Core/integration/input/pointer_states/View.xcui
@@ -1,14 +1,14 @@
+ name="CoreInputPointerStates"
+ theme="../../shared/themes/core_validation.xctheme">
-
+
diff --git a/tests/UI/Editor/integration/input/keyboard_focus/main.cpp b/tests/UI/Core/integration/input/pointer_states/main.cpp
similarity index 57%
rename from tests/UI/Editor/integration/input/keyboard_focus/main.cpp
rename to tests/UI/Core/integration/input/pointer_states/main.cpp
index accce093..bbd96cac 100644
--- a/tests/UI/Editor/integration/input/keyboard_focus/main.cpp
+++ b/tests/UI/Core/integration/input/pointer_states/main.cpp
@@ -1,8 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
- return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
+ return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
- "editor.input.keyboard_focus");
+ "core.input.pointer_states");
}
diff --git a/tests/UI/Core/integration/input/scroll_view/CMakeLists.txt b/tests/UI/Core/integration/input/scroll_view/CMakeLists.txt
new file mode 100644
index 00000000..7b4d6e74
--- /dev/null
+++ b/tests/UI/Core/integration/input/scroll_view/CMakeLists.txt
@@ -0,0 +1,35 @@
+set(CORE_UI_INPUT_SCROLL_VIEW_RESOURCES
+ View.xcui
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
+)
+
+add_executable(core_ui_input_scroll_view_validation WIN32
+ main.cpp
+ ${CORE_UI_INPUT_SCROLL_VIEW_RESOURCES}
+)
+
+target_include_directories(core_ui_input_scroll_view_validation PRIVATE
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_input_scroll_view_validation PRIVATE
+ UNICODE
+ _UNICODE
+)
+
+if(MSVC)
+ target_compile_options(core_ui_input_scroll_view_validation PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_input_scroll_view_validation PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_input_scroll_view_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_input_scroll_view_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreInputScrollViewValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Editor/integration/input/scroll_view/View.xcui b/tests/UI/Core/integration/input/scroll_view/View.xcui
similarity index 95%
rename from tests/UI/Editor/integration/input/scroll_view/View.xcui
rename to tests/UI/Core/integration/input/scroll_view/View.xcui
index 6b086936..bc8996a8 100644
--- a/tests/UI/Editor/integration/input/scroll_view/View.xcui
+++ b/tests/UI/Core/integration/input/scroll_view/View.xcui
@@ -1,6 +1,6 @@
+ name="CoreInputScrollView"
+ theme="../../shared/themes/core_validation.xctheme">
-
+
diff --git a/tests/UI/Editor/integration/input/scroll_view/captures/.gitkeep b/tests/UI/Core/integration/input/scroll_view/captures/.gitkeep
similarity index 100%
rename from tests/UI/Editor/integration/input/scroll_view/captures/.gitkeep
rename to tests/UI/Core/integration/input/scroll_view/captures/.gitkeep
diff --git a/tests/UI/Editor/integration/input/pointer_states/main.cpp b/tests/UI/Core/integration/input/scroll_view/main.cpp
similarity index 57%
rename from tests/UI/Editor/integration/input/pointer_states/main.cpp
rename to tests/UI/Core/integration/input/scroll_view/main.cpp
index 21051e1d..7c44a1ce 100644
--- a/tests/UI/Editor/integration/input/pointer_states/main.cpp
+++ b/tests/UI/Core/integration/input/scroll_view/main.cpp
@@ -1,8 +1,8 @@
#include "Application.h"
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
- return XCEngine::Tests::EditorUI::RunEditorUIValidationApp(
+ return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
hInstance,
nCmdShow,
- "editor.input.pointer_states");
+ "core.input.scroll_view");
}
diff --git a/tests/UI/Core/integration/input/shortcut_scope/CMakeLists.txt b/tests/UI/Core/integration/input/shortcut_scope/CMakeLists.txt
new file mode 100644
index 00000000..1758325c
--- /dev/null
+++ b/tests/UI/Core/integration/input/shortcut_scope/CMakeLists.txt
@@ -0,0 +1,35 @@
+set(CORE_UI_INPUT_SHORTCUT_SCOPE_RESOURCES
+ View.xcui
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
+)
+
+add_executable(core_ui_input_shortcut_scope_validation WIN32
+ main.cpp
+ ${CORE_UI_INPUT_SHORTCUT_SCOPE_RESOURCES}
+)
+
+target_include_directories(core_ui_input_shortcut_scope_validation PRIVATE
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_input_shortcut_scope_validation PRIVATE
+ UNICODE
+ _UNICODE
+)
+
+if(MSVC)
+ target_compile_options(core_ui_input_shortcut_scope_validation PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_input_shortcut_scope_validation PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_input_shortcut_scope_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_input_shortcut_scope_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreInputShortcutScopeValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Editor/integration/input/shortcut_scope/View.xcui b/tests/UI/Core/integration/input/shortcut_scope/View.xcui
similarity index 91%
rename from tests/UI/Editor/integration/input/shortcut_scope/View.xcui
rename to tests/UI/Core/integration/input/shortcut_scope/View.xcui
index 40f2fa18..414e2cf9 100644
--- a/tests/UI/Editor/integration/input/shortcut_scope/View.xcui
+++ b/tests/UI/Core/integration/input/shortcut_scope/View.xcui
@@ -1,13 +1,13 @@
diff --git a/tests/UI/Core/integration/input/shortcut_scope/main.cpp b/tests/UI/Core/integration/input/shortcut_scope/main.cpp
new file mode 100644
index 00000000..ff2822ce
--- /dev/null
+++ b/tests/UI/Core/integration/input/shortcut_scope/main.cpp
@@ -0,0 +1,8 @@
+#include "Application.h"
+
+int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
+ return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
+ hInstance,
+ nCmdShow,
+ "core.input.shortcut_scope");
+}
diff --git a/tests/UI/Core/integration/layout/CMakeLists.txt b/tests/UI/Core/integration/layout/CMakeLists.txt
new file mode 100644
index 00000000..af3a8c5e
--- /dev/null
+++ b/tests/UI/Core/integration/layout/CMakeLists.txt
@@ -0,0 +1,10 @@
+add_subdirectory(splitter_resize)
+add_subdirectory(tab_strip_selection)
+add_subdirectory(workspace_compose)
+
+add_custom_target(core_ui_layout_integration_tests
+ DEPENDS
+ core_ui_layout_splitter_resize_validation
+ core_ui_layout_tab_strip_selection_validation
+ core_ui_layout_workspace_compose_validation
+)
diff --git a/tests/UI/Core/integration/layout/splitter_resize/CMakeLists.txt b/tests/UI/Core/integration/layout/splitter_resize/CMakeLists.txt
new file mode 100644
index 00000000..98927485
--- /dev/null
+++ b/tests/UI/Core/integration/layout/splitter_resize/CMakeLists.txt
@@ -0,0 +1,35 @@
+set(CORE_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES
+ View.xcui
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
+)
+
+add_executable(core_ui_layout_splitter_resize_validation WIN32
+ main.cpp
+ ${CORE_UI_LAYOUT_SPLITTER_RESIZE_RESOURCES}
+)
+
+target_include_directories(core_ui_layout_splitter_resize_validation PRIVATE
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_layout_splitter_resize_validation PRIVATE
+ UNICODE
+ _UNICODE
+)
+
+if(MSVC)
+ target_compile_options(core_ui_layout_splitter_resize_validation PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_layout_splitter_resize_validation PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_layout_splitter_resize_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_layout_splitter_resize_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreLayoutSplitterResizeValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Editor/integration/layout/splitter_resize/View.xcui b/tests/UI/Core/integration/layout/splitter_resize/View.xcui
similarity index 93%
rename from tests/UI/Editor/integration/layout/splitter_resize/View.xcui
rename to tests/UI/Core/integration/layout/splitter_resize/View.xcui
index aba35370..a059f34c 100644
--- a/tests/UI/Editor/integration/layout/splitter_resize/View.xcui
+++ b/tests/UI/Core/integration/layout/splitter_resize/View.xcui
@@ -1,6 +1,6 @@
+ name="CoreSplitterResizeValidation"
+ theme="../../shared/themes/core_validation.xctheme">
:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_layout_tab_strip_selection_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_layout_tab_strip_selection_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreLayoutTabStripSelectionValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui b/tests/UI/Core/integration/layout/tab_strip_selection/View.xcui
similarity index 90%
rename from tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui
rename to tests/UI/Core/integration/layout/tab_strip_selection/View.xcui
index fab8a51c..3fa5ca64 100644
--- a/tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui
+++ b/tests/UI/Core/integration/layout/tab_strip_selection/View.xcui
@@ -1,6 +1,6 @@
+ name="CoreTabStripSelectionValidation"
+ theme="../../shared/themes/core_validation.xctheme">
-
+
diff --git a/tests/UI/Editor/integration/layout/tab_strip_selection/captures/.gitkeep b/tests/UI/Core/integration/layout/tab_strip_selection/captures/.gitkeep
similarity index 100%
rename from tests/UI/Editor/integration/layout/tab_strip_selection/captures/.gitkeep
rename to tests/UI/Core/integration/layout/tab_strip_selection/captures/.gitkeep
diff --git a/tests/UI/Core/integration/layout/tab_strip_selection/main.cpp b/tests/UI/Core/integration/layout/tab_strip_selection/main.cpp
new file mode 100644
index 00000000..cb560bba
--- /dev/null
+++ b/tests/UI/Core/integration/layout/tab_strip_selection/main.cpp
@@ -0,0 +1,8 @@
+#include "Application.h"
+
+int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
+ return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
+ hInstance,
+ nCmdShow,
+ "core.layout.tab_strip_selection");
+}
diff --git a/tests/UI/Core/integration/layout/workspace_compose/CMakeLists.txt b/tests/UI/Core/integration/layout/workspace_compose/CMakeLists.txt
new file mode 100644
index 00000000..dd393258
--- /dev/null
+++ b/tests/UI/Core/integration/layout/workspace_compose/CMakeLists.txt
@@ -0,0 +1,35 @@
+set(CORE_UI_LAYOUT_WORKSPACE_COMPOSE_RESOURCES
+ View.xcui
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
+)
+
+add_executable(core_ui_layout_workspace_compose_validation WIN32
+ main.cpp
+ ${CORE_UI_LAYOUT_WORKSPACE_COMPOSE_RESOURCES}
+)
+
+target_include_directories(core_ui_layout_workspace_compose_validation PRIVATE
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_layout_workspace_compose_validation PRIVATE
+ UNICODE
+ _UNICODE
+)
+
+if(MSVC)
+ target_compile_options(core_ui_layout_workspace_compose_validation PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_layout_workspace_compose_validation PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_layout_workspace_compose_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_layout_workspace_compose_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreLayoutWorkspaceComposeValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Editor/integration/layout/workspace_compose/View.xcui b/tests/UI/Core/integration/layout/workspace_compose/View.xcui
similarity index 94%
rename from tests/UI/Editor/integration/layout/workspace_compose/View.xcui
rename to tests/UI/Core/integration/layout/workspace_compose/View.xcui
index c8dec689..f693be5b 100644
--- a/tests/UI/Editor/integration/layout/workspace_compose/View.xcui
+++ b/tests/UI/Core/integration/layout/workspace_compose/View.xcui
@@ -1,10 +1,10 @@
+ name="CoreWorkspaceComposeValidation"
+ theme="../../shared/themes/core_validation.xctheme">
diff --git a/tests/UI/Editor/integration/layout/workspace_compose/captures/.gitkeep b/tests/UI/Core/integration/layout/workspace_compose/captures/.gitkeep
similarity index 100%
rename from tests/UI/Editor/integration/layout/workspace_compose/captures/.gitkeep
rename to tests/UI/Core/integration/layout/workspace_compose/captures/.gitkeep
diff --git a/tests/UI/Core/integration/layout/workspace_compose/main.cpp b/tests/UI/Core/integration/layout/workspace_compose/main.cpp
new file mode 100644
index 00000000..d50015be
--- /dev/null
+++ b/tests/UI/Core/integration/layout/workspace_compose/main.cpp
@@ -0,0 +1,8 @@
+#include "Application.h"
+
+int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
+ return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
+ hInstance,
+ nCmdShow,
+ "core.layout.workspace_compose");
+}
diff --git a/tests/UI/Core/integration/shared/CMakeLists.txt b/tests/UI/Core/integration/shared/CMakeLists.txt
new file mode 100644
index 00000000..2e406eb4
--- /dev/null
+++ b/tests/UI/Core/integration/shared/CMakeLists.txt
@@ -0,0 +1,61 @@
+file(TO_CMAKE_PATH "${CMAKE_SOURCE_DIR}" XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH)
+
+add_library(core_ui_validation_registry STATIC
+ src/CoreValidationScenario.cpp
+)
+
+target_include_directories(core_ui_validation_registry
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_validation_registry
+ PUBLIC
+ XCENGINE_CORE_UI_TESTS_REPO_ROOT="${XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH}"
+)
+
+if(MSVC)
+ target_compile_options(core_ui_validation_registry PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_validation_registry PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_validation_registry
+ PUBLIC
+ XCEngine
+)
+
+add_library(core_ui_integration_host STATIC
+ src/AutoScreenshot.cpp
+ src/Application.cpp
+ src/NativeRenderer.cpp
+)
+
+target_include_directories(core_ui_integration_host
+ PUBLIC
+ ${CMAKE_CURRENT_SOURCE_DIR}/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_integration_host
+ PUBLIC
+ UNICODE
+ _UNICODE
+ XCENGINE_CORE_UI_TESTS_REPO_ROOT="${XCENGINE_CORE_UI_TESTS_REPO_ROOT_PATH}"
+)
+
+if(MSVC)
+ target_compile_options(core_ui_integration_host PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_integration_host PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_integration_host
+ PUBLIC
+ core_ui_validation_registry
+ XCEngine
+ d2d1.lib
+ dwrite.lib
+ windowscodecs.lib
+)
diff --git a/tests/UI/Core/integration/shared/src/Application.cpp b/tests/UI/Core/integration/shared/src/Application.cpp
new file mode 100644
index 00000000..68251e27
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/Application.cpp
@@ -0,0 +1,802 @@
+#include "Application.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifndef XCENGINE_CORE_UI_TESTS_REPO_ROOT
+#define XCENGINE_CORE_UI_TESTS_REPO_ROOT "."
+#endif
+
+namespace XCEngine::Tests::CoreUI {
+
+namespace {
+
+using ::XCEngine::UI::UIColor;
+using ::XCEngine::UI::UIDrawData;
+using ::XCEngine::UI::UIDrawList;
+using ::XCEngine::UI::UIInputEvent;
+using ::XCEngine::UI::UIInputEventType;
+using ::XCEngine::UI::UIPoint;
+using ::XCEngine::UI::UIPointerButton;
+using ::XCEngine::UI::UIRect;
+using ::XCEngine::UI::Runtime::UIScreenFrameInput;
+using ::XCEngine::Input::KeyCode;
+
+constexpr const wchar_t* kWindowClassName = L"XCUICoreValidationHost";
+constexpr const wchar_t* kWindowTitle = L"XCUI Core Validation";
+constexpr auto kReloadPollInterval = std::chrono::milliseconds(150);
+
+constexpr UIColor kOverlayBgColor(0.10f, 0.10f, 0.10f, 0.95f);
+constexpr UIColor kOverlayBorderColor(0.25f, 0.25f, 0.25f, 1.0f);
+constexpr UIColor kOverlayTextPrimary(0.93f, 0.93f, 0.93f, 1.0f);
+constexpr UIColor kOverlayTextMuted(0.70f, 0.70f, 0.70f, 1.0f);
+constexpr UIColor kOverlaySuccess(0.82f, 0.82f, 0.82f, 1.0f);
+constexpr UIColor kOverlayFallback(0.56f, 0.56f, 0.56f, 1.0f);
+
+Application* GetApplicationFromWindow(HWND hwnd) {
+ return reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
+}
+
+std::filesystem::path GetRepoRootPath() {
+ std::string root = XCENGINE_CORE_UI_TESTS_REPO_ROOT;
+ if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
+ root = root.substr(1u, root.size() - 2u);
+ }
+ return std::filesystem::path(root).lexically_normal();
+}
+
+std::string TruncateText(const std::string& text, std::size_t maxLength) {
+ if (text.size() <= maxLength) {
+ return text;
+ }
+
+ if (maxLength <= 3u) {
+ return text.substr(0, maxLength);
+ }
+
+ return text.substr(0, maxLength - 3u) + "...";
+}
+
+std::string ExtractStateKeyTail(const std::string& stateKey) {
+ if (stateKey.empty()) {
+ return "-";
+ }
+
+ const std::size_t separator = stateKey.find_last_of('/');
+ if (separator == std::string::npos || separator + 1u >= stateKey.size()) {
+ return stateKey;
+ }
+
+ return stateKey.substr(separator + 1u);
+}
+
+std::string FormatFloat(float value) {
+ std::ostringstream stream;
+ stream.setf(std::ios::fixed, std::ios::floatfield);
+ stream.precision(1);
+ stream << value;
+ return stream.str();
+}
+
+std::string FormatPoint(const UIPoint& point) {
+ return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")";
+}
+
+std::string FormatRect(const UIRect& rect) {
+ return "(" + FormatFloat(rect.x) +
+ ", " + FormatFloat(rect.y) +
+ ", " + FormatFloat(rect.width) +
+ ", " + FormatFloat(rect.height) +
+ ")";
+}
+
+std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) {
+ switch (wParam) {
+ case 'A': return static_cast(KeyCode::A);
+ case 'B': return static_cast(KeyCode::B);
+ case 'C': return static_cast(KeyCode::C);
+ case 'D': return static_cast(KeyCode::D);
+ case 'E': return static_cast(KeyCode::E);
+ case 'F': return static_cast(KeyCode::F);
+ case 'G': return static_cast(KeyCode::G);
+ case 'H': return static_cast(KeyCode::H);
+ case 'I': return static_cast(KeyCode::I);
+ case 'J': return static_cast(KeyCode::J);
+ case 'K': return static_cast(KeyCode::K);
+ case 'L': return static_cast(KeyCode::L);
+ case 'M': return static_cast(KeyCode::M);
+ case 'N': return static_cast(KeyCode::N);
+ case 'O': return static_cast(KeyCode::O);
+ case 'P': return static_cast(KeyCode::P);
+ case 'Q': return static_cast(KeyCode::Q);
+ case 'R': return static_cast(KeyCode::R);
+ case 'S': return static_cast(KeyCode::S);
+ case 'T': return static_cast(KeyCode::T);
+ case 'U': return static_cast(KeyCode::U);
+ case 'V': return static_cast(KeyCode::V);
+ case 'W': return static_cast(KeyCode::W);
+ case 'X': return static_cast(KeyCode::X);
+ case 'Y': return static_cast(KeyCode::Y);
+ case 'Z': return static_cast(KeyCode::Z);
+ case '0': return static_cast(KeyCode::Zero);
+ case '1': return static_cast(KeyCode::One);
+ case '2': return static_cast(KeyCode::Two);
+ case '3': return static_cast(KeyCode::Three);
+ case '4': return static_cast(KeyCode::Four);
+ case '5': return static_cast(KeyCode::Five);
+ case '6': return static_cast(KeyCode::Six);
+ case '7': return static_cast(KeyCode::Seven);
+ case '8': return static_cast(KeyCode::Eight);
+ case '9': return static_cast(KeyCode::Nine);
+ case VK_SPACE: return static_cast(KeyCode::Space);
+ case VK_TAB: return static_cast(KeyCode::Tab);
+ case VK_RETURN: return static_cast(KeyCode::Enter);
+ case VK_ESCAPE: return static_cast(KeyCode::Escape);
+ case VK_SHIFT: return static_cast(KeyCode::LeftShift);
+ case VK_CONTROL: return static_cast(KeyCode::LeftCtrl);
+ case VK_MENU: return static_cast(KeyCode::LeftAlt);
+ case VK_UP: return static_cast(KeyCode::Up);
+ case VK_DOWN: return static_cast(KeyCode::Down);
+ case VK_LEFT: return static_cast(KeyCode::Left);
+ case VK_RIGHT: return static_cast(KeyCode::Right);
+ case VK_HOME: return static_cast(KeyCode::Home);
+ case VK_END: return static_cast(KeyCode::End);
+ case VK_PRIOR: return static_cast(KeyCode::PageUp);
+ case VK_NEXT: return static_cast(KeyCode::PageDown);
+ case VK_DELETE: return static_cast(KeyCode::Delete);
+ case VK_BACK: return static_cast(KeyCode::Backspace);
+ case VK_F1: return static_cast(KeyCode::F1);
+ case VK_F2: return static_cast(KeyCode::F2);
+ case VK_F3: return static_cast(KeyCode::F3);
+ case VK_F4: return static_cast(KeyCode::F4);
+ case VK_F5: return static_cast(KeyCode::F5);
+ case VK_F6: return static_cast(KeyCode::F6);
+ case VK_F7: return static_cast(KeyCode::F7);
+ case VK_F8: return static_cast(KeyCode::F8);
+ case VK_F9: return static_cast(KeyCode::F9);
+ case VK_F10: return static_cast(KeyCode::F10);
+ case VK_F11: return static_cast(KeyCode::F11);
+ case VK_F12: return static_cast(KeyCode::F12);
+ default: return static_cast(KeyCode::None);
+ }
+}
+
+bool IsRepeatKeyMessage(LPARAM lParam) {
+ return (static_cast(lParam) & (1ul << 30)) != 0ul;
+}
+
+} // namespace
+
+Application::Application(std::string requestedScenarioId)
+ : m_screenPlayer(m_documentHost)
+ , m_requestedScenarioId(std::move(requestedScenarioId)) {
+}
+
+int Application::Run(HINSTANCE hInstance, int nCmdShow) {
+ if (!Initialize(hInstance, nCmdShow)) {
+ Shutdown();
+ return 1;
+ }
+
+ MSG message = {};
+ while (message.message != WM_QUIT) {
+ if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) {
+ TranslateMessage(&message);
+ DispatchMessageW(&message);
+ continue;
+ }
+
+ RenderFrame();
+ Sleep(8);
+ }
+
+ Shutdown();
+ return static_cast(message.wParam);
+}
+
+bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
+ m_hInstance = hInstance;
+
+ WNDCLASSEXW windowClass = {};
+ windowClass.cbSize = sizeof(windowClass);
+ windowClass.style = CS_HREDRAW | CS_VREDRAW;
+ windowClass.lpfnWndProc = &Application::WndProc;
+ windowClass.hInstance = hInstance;
+ windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
+ windowClass.lpszClassName = kWindowClassName;
+
+ m_windowClassAtom = RegisterClassExW(&windowClass);
+ if (m_windowClassAtom == 0) {
+ return false;
+ }
+
+ m_hwnd = CreateWindowExW(
+ 0,
+ kWindowClassName,
+ kWindowTitle,
+ WS_OVERLAPPEDWINDOW | WS_VISIBLE,
+ CW_USEDEFAULT,
+ CW_USEDEFAULT,
+ 1440,
+ 900,
+ nullptr,
+ nullptr,
+ hInstance,
+ this);
+ if (m_hwnd == nullptr) {
+ return false;
+ }
+
+ ShowWindow(m_hwnd, nCmdShow);
+ UpdateWindow(m_hwnd);
+
+ if (!m_renderer.Initialize(m_hwnd)) {
+ return false;
+ }
+
+ m_startTime = std::chrono::steady_clock::now();
+ m_lastFrameTime = m_startTime;
+ const CoreValidationScenario* initialScenario = m_requestedScenarioId.empty()
+ ? &GetDefaultCoreValidationScenario()
+ : FindCoreValidationScenario(m_requestedScenarioId);
+ if (initialScenario == nullptr) {
+ initialScenario = &GetDefaultCoreValidationScenario();
+ }
+ m_autoScreenshot.Initialize(initialScenario->captureRootPath);
+ LoadStructuredScreen("startup");
+ return true;
+}
+
+void Application::Shutdown() {
+ m_autoScreenshot.Shutdown();
+ m_screenPlayer.Unload();
+ m_trackedFiles.clear();
+ m_screenAsset = {};
+ m_useStructuredScreen = false;
+ m_runtimeStatus.clear();
+ m_runtimeError.clear();
+ m_frameIndex = 0;
+
+ m_renderer.Shutdown();
+
+ if (m_hwnd != nullptr && IsWindow(m_hwnd)) {
+ DestroyWindow(m_hwnd);
+ }
+ m_hwnd = nullptr;
+
+ if (m_windowClassAtom != 0 && m_hInstance != nullptr) {
+ UnregisterClassW(kWindowClassName, m_hInstance);
+ m_windowClassAtom = 0;
+ }
+}
+
+void Application::RenderFrame() {
+ if (m_hwnd == nullptr) {
+ return;
+ }
+
+ RECT clientRect = {};
+ GetClientRect(m_hwnd, &clientRect);
+ const float width = static_cast((std::max)(clientRect.right - clientRect.left, 1L));
+ const float height = static_cast((std::max)(clientRect.bottom - clientRect.top, 1L));
+
+ const auto now = std::chrono::steady_clock::now();
+ double deltaTimeSeconds = std::chrono::duration(now - m_lastFrameTime).count();
+ if (deltaTimeSeconds <= 0.0) {
+ deltaTimeSeconds = 1.0 / 60.0;
+ }
+ m_lastFrameTime = now;
+
+ RefreshStructuredScreen();
+ std::vector frameEvents = std::move(m_pendingInputEvents);
+ m_pendingInputEvents.clear();
+
+ UIDrawData drawData = {};
+ if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) {
+ UIScreenFrameInput input = {};
+ input.viewportRect = UIRect(0.0f, 0.0f, width, height);
+ input.events = std::move(frameEvents);
+ input.deltaTimeSeconds = deltaTimeSeconds;
+ input.frameIndex = ++m_frameIndex;
+ input.focused = GetForegroundWindow() == m_hwnd;
+
+ const auto& frame = m_screenPlayer.Update(input);
+ for (const auto& drawList : frame.drawData.GetDrawLists()) {
+ drawData.AddDrawList(drawList);
+ }
+
+ m_runtimeStatus = m_activeScenario != nullptr
+ ? m_activeScenario->displayName
+ : "Core UI Validation";
+ m_runtimeError = frame.errorMessage;
+ }
+
+ if (drawData.Empty()) {
+ m_runtimeStatus = "Core UI Validation | Load Error";
+ if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) {
+ m_runtimeError = m_screenPlayer.GetLastError();
+ }
+ }
+
+ AppendRuntimeOverlay(drawData, width, height);
+
+ const bool framePresented = m_renderer.Render(drawData);
+ m_autoScreenshot.CaptureIfRequested(
+ m_renderer,
+ drawData,
+ static_cast(width),
+ static_cast(height),
+ framePresented);
+}
+
+void Application::OnResize(UINT width, UINT height) {
+ if (width == 0 || height == 0) {
+ return;
+ }
+
+ m_renderer.Resize(width, height);
+}
+
+void Application::QueuePointerEvent(UIInputEventType type, UIPointerButton button, WPARAM wParam, LPARAM lParam) {
+ UIInputEvent event = {};
+ event.type = type;
+ event.pointerButton = button;
+ event.position = UIPoint(
+ static_cast(GET_X_LPARAM(lParam)),
+ static_cast(GET_Y_LPARAM(lParam)));
+ event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast(wParam));
+ m_pendingInputEvents.push_back(event);
+}
+
+void Application::QueuePointerLeaveEvent() {
+ UIInputEvent event = {};
+ event.type = UIInputEventType::PointerLeave;
+ if (m_hwnd != nullptr) {
+ POINT clientPoint = {};
+ GetCursorPos(&clientPoint);
+ ScreenToClient(m_hwnd, &clientPoint);
+ event.position = UIPoint(static_cast(clientPoint.x), static_cast(clientPoint.y));
+ }
+ m_pendingInputEvents.push_back(event);
+}
+
+void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam) {
+ if (m_hwnd == nullptr) {
+ return;
+ }
+
+ POINT screenPoint = {
+ GET_X_LPARAM(lParam),
+ GET_Y_LPARAM(lParam)
+ };
+ ScreenToClient(m_hwnd, &screenPoint);
+
+ UIInputEvent event = {};
+ event.type = UIInputEventType::PointerWheel;
+ event.position = UIPoint(static_cast(screenPoint.x), static_cast(screenPoint.y));
+ event.wheelDelta = static_cast(wheelDelta);
+ event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast(wParam));
+ m_pendingInputEvents.push_back(event);
+}
+
+void Application::QueueKeyEvent(UIInputEventType type, WPARAM wParam, LPARAM lParam) {
+ UIInputEvent event = {};
+ event.type = type;
+ event.keyCode = MapVirtualKeyToUIKeyCode(wParam);
+ event.modifiers = m_inputModifierTracker.ApplyKeyMessage(type, wParam, lParam);
+ event.repeat = IsRepeatKeyMessage(lParam);
+ m_pendingInputEvents.push_back(event);
+}
+
+void Application::QueueCharacterEvent(WPARAM wParam, LPARAM) {
+ UIInputEvent event = {};
+ event.type = UIInputEventType::Character;
+ event.character = static_cast(wParam);
+ event.modifiers = m_inputModifierTracker.GetCurrentModifiers();
+ m_pendingInputEvents.push_back(event);
+}
+
+void Application::QueueWindowFocusEvent(UIInputEventType type) {
+ UIInputEvent event = {};
+ event.type = type;
+ m_pendingInputEvents.push_back(event);
+}
+
+bool Application::LoadStructuredScreen(const char* triggerReason) {
+ (void)triggerReason;
+ std::string scenarioLoadWarning = {};
+ const CoreValidationScenario* scenario = m_requestedScenarioId.empty()
+ ? &GetDefaultCoreValidationScenario()
+ : FindCoreValidationScenario(m_requestedScenarioId);
+ if (scenario == nullptr) {
+ scenario = &GetDefaultCoreValidationScenario();
+ scenarioLoadWarning = "Unknown validation scenario: " + m_requestedScenarioId;
+ }
+
+ m_activeScenario = scenario;
+ m_screenAsset = {};
+ m_screenAsset.screenId = scenario->id;
+ m_screenAsset.documentPath = scenario->documentPath.string();
+ m_screenAsset.themePath = scenario->themePath.string();
+
+ const bool loaded = m_screenPlayer.Load(m_screenAsset);
+ m_useStructuredScreen = loaded;
+ m_runtimeStatus = loaded ? scenario->displayName : "Core UI Validation | Load Error";
+ m_runtimeError = loaded
+ ? scenarioLoadWarning
+ : (scenarioLoadWarning.empty()
+ ? m_screenPlayer.GetLastError()
+ : scenarioLoadWarning + " | " + m_screenPlayer.GetLastError());
+ RebuildTrackedFileStates();
+ return loaded;
+}
+
+void Application::RefreshStructuredScreen() {
+ const auto now = std::chrono::steady_clock::now();
+ if (m_lastReloadPollTime.time_since_epoch().count() != 0 &&
+ now - m_lastReloadPollTime < kReloadPollInterval) {
+ return;
+ }
+
+ m_lastReloadPollTime = now;
+ if (DetectTrackedFileChange()) {
+ LoadStructuredScreen("reload");
+ }
+}
+
+void Application::RebuildTrackedFileStates() {
+ namespace fs = std::filesystem;
+
+ m_trackedFiles.clear();
+ std::unordered_set seenPaths = {};
+ std::error_code errorCode = {};
+
+ auto appendTrackedPath = [&](const std::string& rawPath) {
+ if (rawPath.empty()) {
+ return;
+ }
+
+ const fs::path normalizedPath = fs::path(rawPath).lexically_normal();
+ const std::string key = normalizedPath.string();
+ if (!seenPaths.insert(key).second) {
+ return;
+ }
+
+ TrackedFileState state = {};
+ state.path = normalizedPath;
+ state.exists = fs::exists(normalizedPath, errorCode);
+ errorCode.clear();
+ if (state.exists) {
+ state.writeTime = fs::last_write_time(normalizedPath, errorCode);
+ errorCode.clear();
+ }
+ m_trackedFiles.push_back(std::move(state));
+ };
+
+ appendTrackedPath(m_screenAsset.documentPath);
+ appendTrackedPath(m_screenAsset.themePath);
+
+ if (const auto* document = m_screenPlayer.GetDocument(); document != nullptr) {
+ for (const std::string& dependency : document->dependencies) {
+ appendTrackedPath(dependency);
+ }
+ }
+}
+
+bool Application::DetectTrackedFileChange() const {
+ namespace fs = std::filesystem;
+
+ std::error_code errorCode = {};
+ for (const TrackedFileState& trackedFile : m_trackedFiles) {
+ const bool existsNow = fs::exists(trackedFile.path, errorCode);
+ errorCode.clear();
+ if (existsNow != trackedFile.exists) {
+ return true;
+ }
+
+ if (!existsNow) {
+ continue;
+ }
+
+ const auto writeTimeNow = fs::last_write_time(trackedFile.path, errorCode);
+ errorCode.clear();
+ if (writeTimeNow != trackedFile.writeTime) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const {
+ const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded();
+ const float panelWidth = authoredMode ? 460.0f : 360.0f;
+ std::vector detailLines = {};
+ detailLines.push_back(
+ authoredMode
+ ? "Hot reload watches authored UI resources."
+ : "Authored validation scene failed to load.");
+ if (m_activeScenario != nullptr) {
+ detailLines.push_back("Scenario: " + m_activeScenario->id);
+ }
+
+ if (authoredMode) {
+ const auto& inputDebug = m_documentHost.GetInputDebugSnapshot();
+ const auto& scrollDebug = m_documentHost.GetScrollDebugSnapshot();
+ detailLines.push_back(
+ "Hover | Focus: " +
+ ExtractStateKeyTail(inputDebug.hoveredStateKey) +
+ " | " +
+ ExtractStateKeyTail(inputDebug.focusedStateKey));
+ detailLines.push_back(
+ "Active | Capture: " +
+ ExtractStateKeyTail(inputDebug.activeStateKey) +
+ " | " +
+ ExtractStateKeyTail(inputDebug.captureStateKey));
+ detailLines.push_back(
+ "Scope W/P/Wg: " +
+ ExtractStateKeyTail(inputDebug.windowScopeStateKey) +
+ " | " +
+ ExtractStateKeyTail(inputDebug.panelScopeStateKey) +
+ " | " +
+ ExtractStateKeyTail(inputDebug.widgetScopeStateKey));
+ detailLines.push_back(
+ std::string("Text input: ") +
+ (inputDebug.textInputActive ? "active" : "idle"));
+ if (!inputDebug.recentShortcutCommandId.empty()) {
+ detailLines.push_back(
+ "Recent shortcut: " +
+ inputDebug.recentShortcutScope +
+ " -> " +
+ inputDebug.recentShortcutCommandId);
+ detailLines.push_back(
+ std::string("Recent shortcut state: ") +
+ (inputDebug.recentShortcutHandled
+ ? "handled"
+ : (inputDebug.recentShortcutSuppressed ? "suppressed" : "observed")) +
+ " @ " +
+ ExtractStateKeyTail(inputDebug.recentShortcutOwnerStateKey));
+ } else {
+ detailLines.push_back("Recent shortcut: none");
+ }
+ if (!inputDebug.lastEventType.empty()) {
+ const std::string eventPosition = inputDebug.lastEventType == "KeyDown" ||
+ inputDebug.lastEventType == "KeyUp" ||
+ inputDebug.lastEventType == "Character" ||
+ inputDebug.lastEventType == "FocusGained" ||
+ inputDebug.lastEventType == "FocusLost"
+ ? std::string()
+ : " at " + FormatPoint(inputDebug.pointerPosition);
+ detailLines.push_back(
+ "Last input: " +
+ inputDebug.lastEventType +
+ eventPosition);
+ detailLines.push_back(
+ "Route: " +
+ inputDebug.lastTargetKind +
+ " -> " +
+ ExtractStateKeyTail(inputDebug.lastTargetStateKey));
+ if (!inputDebug.lastShortcutCommandId.empty()) {
+ detailLines.push_back(
+ "Shortcut: " +
+ inputDebug.lastShortcutScope +
+ " -> " +
+ inputDebug.lastShortcutCommandId);
+ detailLines.push_back(
+ std::string("Shortcut state: ") +
+ (inputDebug.lastShortcutHandled
+ ? "handled"
+ : (inputDebug.lastShortcutSuppressed ? "suppressed" : "observed")) +
+ " @ " +
+ ExtractStateKeyTail(inputDebug.lastShortcutOwnerStateKey));
+ }
+ detailLines.push_back(
+ "Last event result: " +
+ (inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult));
+ }
+ detailLines.push_back(
+ "Scroll target | Primary: " +
+ ExtractStateKeyTail(scrollDebug.lastTargetStateKey) +
+ " | " +
+ ExtractStateKeyTail(scrollDebug.primaryTargetStateKey));
+ detailLines.push_back(
+ "Scroll offset B/A: " +
+ FormatFloat(scrollDebug.lastOffsetBefore) +
+ " -> " +
+ FormatFloat(scrollDebug.lastOffsetAfter) +
+ " | overflow " +
+ FormatFloat(scrollDebug.lastOverflow));
+ detailLines.push_back(
+ "Scroll H/T: " +
+ std::to_string(scrollDebug.handledWheelEventCount) +
+ "/" +
+ std::to_string(scrollDebug.totalWheelEventCount) +
+ " | " +
+ (scrollDebug.lastResult.empty() ? std::string("n/a") : scrollDebug.lastResult));
+ }
+
+ if (m_autoScreenshot.HasPendingCapture()) {
+ detailLines.push_back("Shot pending...");
+ } else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) {
+ detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureSummary(), 78u));
+ } else {
+ detailLines.push_back("Screenshots: F12 -> current scenario captures/");
+ }
+
+ if (!m_runtimeError.empty()) {
+ detailLines.push_back(TruncateText(m_runtimeError, 78u));
+ } else if (!m_autoScreenshot.GetLastCaptureError().empty()) {
+ detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureError(), 78u));
+ } else if (!authoredMode) {
+ detailLines.push_back("No fallback sandbox is rendered in this host.");
+ }
+
+ const float panelHeight = 38.0f + static_cast(detailLines.size()) * 18.0f;
+ const UIRect panelRect(width - panelWidth - 16.0f, height - panelHeight - 42.0f, panelWidth, panelHeight);
+
+ UIDrawList& overlay = drawData.EmplaceDrawList("Core UI Validation Overlay");
+ overlay.AddFilledRect(panelRect, kOverlayBgColor, 10.0f);
+ overlay.AddRectOutline(panelRect, kOverlayBorderColor, 1.0f, 10.0f);
+ overlay.AddFilledRect(
+ UIRect(panelRect.x + 12.0f, panelRect.y + 14.0f, 8.0f, 8.0f),
+ authoredMode ? kOverlaySuccess : kOverlayFallback,
+ 4.0f);
+ overlay.AddText(
+ UIPoint(panelRect.x + 28.0f, panelRect.y + 10.0f),
+ m_runtimeStatus.empty() ? "Core UI Validation" : m_runtimeStatus,
+ kOverlayTextPrimary,
+ 14.0f);
+
+ float detailY = panelRect.y + 30.0f;
+ for (std::size_t index = 0; index < detailLines.size(); ++index) {
+ const bool lastLine = index + 1u == detailLines.size();
+ overlay.AddText(
+ UIPoint(panelRect.x + 28.0f, detailY),
+ detailLines[index],
+ lastLine && (!m_runtimeError.empty() || !m_autoScreenshot.GetLastCaptureError().empty())
+ ? kOverlayFallback
+ : kOverlayTextMuted,
+ 12.0f);
+ detailY += 18.0f;
+ }
+}
+
+std::filesystem::path Application::ResolveRepoRelativePath(const char* relativePath) {
+ return (GetRepoRootPath() / relativePath).lexically_normal();
+}
+
+LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
+ if (message == WM_NCCREATE) {
+ const auto* createStruct = reinterpret_cast(lParam);
+ auto* application = reinterpret_cast(createStruct->lpCreateParams);
+ SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(application));
+ return TRUE;
+ }
+
+ Application* application = GetApplicationFromWindow(hwnd);
+ switch (message) {
+ case WM_SIZE:
+ if (application != nullptr && wParam != SIZE_MINIMIZED) {
+ application->OnResize(static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam)));
+ }
+ return 0;
+ case WM_PAINT:
+ if (application != nullptr) {
+ PAINTSTRUCT paintStruct = {};
+ BeginPaint(hwnd, &paintStruct);
+ application->RenderFrame();
+ EndPaint(hwnd, &paintStruct);
+ return 0;
+ }
+ break;
+ case WM_MOUSEMOVE:
+ if (application != nullptr) {
+ if (!application->m_trackingMouseLeave) {
+ TRACKMOUSEEVENT trackMouseEvent = {};
+ trackMouseEvent.cbSize = sizeof(trackMouseEvent);
+ trackMouseEvent.dwFlags = TME_LEAVE;
+ trackMouseEvent.hwndTrack = hwnd;
+ if (TrackMouseEvent(&trackMouseEvent)) {
+ application->m_trackingMouseLeave = true;
+ }
+ }
+ application->QueuePointerEvent(UIInputEventType::PointerMove, UIPointerButton::None, wParam, lParam);
+ return 0;
+ }
+ break;
+ case WM_MOUSELEAVE:
+ if (application != nullptr) {
+ application->m_trackingMouseLeave = false;
+ application->QueuePointerLeaveEvent();
+ return 0;
+ }
+ break;
+ case WM_LBUTTONDOWN:
+ if (application != nullptr) {
+ SetFocus(hwnd);
+ SetCapture(hwnd);
+ application->QueuePointerEvent(UIInputEventType::PointerButtonDown, UIPointerButton::Left, wParam, lParam);
+ return 0;
+ }
+ break;
+ case WM_LBUTTONUP:
+ if (application != nullptr) {
+ if (GetCapture() == hwnd) {
+ ReleaseCapture();
+ }
+ application->QueuePointerEvent(UIInputEventType::PointerButtonUp, UIPointerButton::Left, wParam, lParam);
+ return 0;
+ }
+ break;
+ case WM_MOUSEWHEEL:
+ if (application != nullptr) {
+ application->QueuePointerWheelEvent(GET_WHEEL_DELTA_WPARAM(wParam), wParam, lParam);
+ return 0;
+ }
+ break;
+ case WM_SETFOCUS:
+ if (application != nullptr) {
+ application->m_inputModifierTracker.SyncFromSystemState();
+ application->QueueWindowFocusEvent(UIInputEventType::FocusGained);
+ return 0;
+ }
+ break;
+ case WM_KILLFOCUS:
+ if (application != nullptr) {
+ application->m_inputModifierTracker.Reset();
+ application->QueueWindowFocusEvent(UIInputEventType::FocusLost);
+ return 0;
+ }
+ break;
+ case WM_KEYDOWN:
+ case WM_SYSKEYDOWN:
+ if (application != nullptr) {
+ if (wParam == VK_F12) {
+ application->m_autoScreenshot.RequestCapture("manual_f12");
+ }
+ application->QueueKeyEvent(UIInputEventType::KeyDown, wParam, lParam);
+ return 0;
+ }
+ break;
+ case WM_KEYUP:
+ case WM_SYSKEYUP:
+ if (application != nullptr) {
+ application->QueueKeyEvent(UIInputEventType::KeyUp, wParam, lParam);
+ return 0;
+ }
+ break;
+ case WM_CHAR:
+ if (application != nullptr) {
+ application->QueueCharacterEvent(wParam, lParam);
+ return 0;
+ }
+ break;
+ case WM_ERASEBKGND:
+ return 1;
+ case WM_DESTROY:
+ if (application != nullptr) {
+ application->m_hwnd = nullptr;
+ }
+ PostQuitMessage(0);
+ return 0;
+ default:
+ break;
+ }
+
+ return DefWindowProcW(hwnd, message, wParam, lParam);
+}
+
+int RunCoreUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId) {
+ Application application(std::move(requestedScenarioId));
+ return application.Run(hInstance, nCmdShow);
+}
+
+} // namespace XCEngine::Tests::CoreUI
diff --git a/tests/UI/Core/integration/shared/src/Application.h b/tests/UI/Core/integration/shared/src/Application.h
new file mode 100644
index 00000000..ac331de9
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/Application.h
@@ -0,0 +1,83 @@
+#pragma once
+
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+
+#include "AutoScreenshot.h"
+#include "CoreValidationScenario.h"
+#include "InputModifierTracker.h"
+#include "NativeRenderer.h"
+
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace XCEngine::Tests::CoreUI {
+
+class Application {
+public:
+ explicit Application(std::string requestedScenarioId = {});
+
+ int Run(HINSTANCE hInstance, int nCmdShow);
+
+private:
+ struct TrackedFileState {
+ std::filesystem::path path = {};
+ std::filesystem::file_time_type writeTime = {};
+ bool exists = false;
+ };
+
+ static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
+
+ bool Initialize(HINSTANCE hInstance, int nCmdShow);
+ void Shutdown();
+ void RenderFrame();
+ void OnResize(UINT width, UINT height);
+ void QueuePointerEvent(::XCEngine::UI::UIInputEventType type, ::XCEngine::UI::UIPointerButton button, WPARAM wParam, LPARAM lParam);
+ void QueuePointerLeaveEvent();
+ void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam);
+ void QueueKeyEvent(::XCEngine::UI::UIInputEventType type, WPARAM wParam, LPARAM lParam);
+ void QueueCharacterEvent(WPARAM wParam, LPARAM lParam);
+ void QueueWindowFocusEvent(::XCEngine::UI::UIInputEventType type);
+ bool LoadStructuredScreen(const char* triggerReason);
+ void RefreshStructuredScreen();
+ void RebuildTrackedFileStates();
+ bool DetectTrackedFileChange() const;
+ void AppendRuntimeOverlay(::XCEngine::UI::UIDrawData& drawData, float width, float height) const;
+ static std::filesystem::path ResolveRepoRelativePath(const char* relativePath);
+
+ HWND m_hwnd = nullptr;
+ HINSTANCE m_hInstance = nullptr;
+ ATOM m_windowClassAtom = 0;
+ Host::NativeRenderer m_renderer;
+ Host::AutoScreenshotController m_autoScreenshot;
+ ::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost;
+ ::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer;
+ ::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {};
+ const CoreValidationScenario* m_activeScenario = nullptr;
+ std::string m_requestedScenarioId = {};
+ std::vector m_trackedFiles = {};
+ std::chrono::steady_clock::time_point m_startTime = {};
+ std::chrono::steady_clock::time_point m_lastFrameTime = {};
+ std::chrono::steady_clock::time_point m_lastReloadPollTime = {};
+ std::uint64_t m_frameIndex = 0;
+ std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {};
+ Host::InputModifierTracker m_inputModifierTracker = {};
+ bool m_trackingMouseLeave = false;
+ bool m_useStructuredScreen = false;
+ std::string m_runtimeStatus = {};
+ std::string m_runtimeError = {};
+};
+
+int RunCoreUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId = {});
+
+} // namespace XCEngine::Tests::CoreUI
diff --git a/tests/UI/Core/integration/shared/src/AutoScreenshot.cpp b/tests/UI/Core/integration/shared/src/AutoScreenshot.cpp
new file mode 100644
index 00000000..d75401f1
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/AutoScreenshot.cpp
@@ -0,0 +1,165 @@
+#include "AutoScreenshot.h"
+
+#include "NativeRenderer.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace XCEngine::Tests::CoreUI::Host {
+
+void AutoScreenshotController::Initialize(const std::filesystem::path& captureRoot) {
+ m_captureRoot = captureRoot.lexically_normal();
+ m_historyRoot = (m_captureRoot / "history").lexically_normal();
+ m_latestCapturePath = (m_captureRoot / "latest.png").lexically_normal();
+ m_captureCount = 0;
+ m_capturePending = false;
+ m_pendingReason.clear();
+ m_lastCaptureSummary.clear();
+ m_lastCaptureError.clear();
+}
+
+void AutoScreenshotController::Shutdown() {
+ m_capturePending = false;
+ m_pendingReason.clear();
+}
+
+void AutoScreenshotController::RequestCapture(std::string reason) {
+ m_pendingReason = reason.empty() ? "capture" : std::move(reason);
+ m_capturePending = true;
+}
+
+void AutoScreenshotController::CaptureIfRequested(
+ NativeRenderer& renderer,
+ const ::XCEngine::UI::UIDrawData& drawData,
+ unsigned int width,
+ unsigned int height,
+ bool framePresented) {
+ if (!m_capturePending || !framePresented || drawData.Empty() || width == 0u || height == 0u) {
+ return;
+ }
+
+ std::error_code errorCode = {};
+ std::filesystem::create_directories(m_captureRoot, errorCode);
+ if (errorCode) {
+ m_lastCaptureError = "Failed to create screenshot directory: " + m_captureRoot.string();
+ m_lastCaptureSummary = "AutoShot failed";
+ m_capturePending = false;
+ return;
+ }
+
+ std::filesystem::create_directories(m_historyRoot, errorCode);
+ if (errorCode) {
+ m_lastCaptureError = "Failed to create screenshot directory: " + m_historyRoot.string();
+ m_lastCaptureSummary = "AutoShot failed";
+ m_capturePending = false;
+ return;
+ }
+
+ std::string captureError = {};
+ const std::filesystem::path historyPath = BuildHistoryCapturePath(m_pendingReason);
+ if (!renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) {
+ m_lastCaptureError = std::move(captureError);
+ m_lastCaptureSummary = "AutoShot failed";
+ m_capturePending = false;
+ return;
+ }
+
+ errorCode.clear();
+ std::filesystem::copy_file(
+ historyPath,
+ m_latestCapturePath,
+ std::filesystem::copy_options::overwrite_existing,
+ errorCode);
+ if (errorCode) {
+ m_lastCaptureError = "Failed to update latest screenshot: " + m_latestCapturePath.string();
+ m_lastCaptureSummary = "AutoShot failed";
+ m_capturePending = false;
+ return;
+ }
+
+ ++m_captureCount;
+ m_lastCaptureError.clear();
+ m_lastCaptureSummary = "Shot: latest.png | " + historyPath.filename().string();
+ m_capturePending = false;
+ m_pendingReason.clear();
+}
+
+bool AutoScreenshotController::HasPendingCapture() const {
+ return m_capturePending;
+}
+
+const std::filesystem::path& AutoScreenshotController::GetLatestCapturePath() const {
+ return m_latestCapturePath;
+}
+
+const std::string& AutoScreenshotController::GetLastCaptureSummary() const {
+ return m_lastCaptureSummary;
+}
+
+const std::string& AutoScreenshotController::GetLastCaptureError() const {
+ return m_lastCaptureError;
+}
+
+std::filesystem::path AutoScreenshotController::BuildHistoryCapturePath(std::string_view reason) const {
+ std::ostringstream filename;
+ filename << BuildTimestampString()
+ << '_'
+ << (m_captureCount + 1u)
+ << '_'
+ << SanitizeReason(reason)
+ << ".png";
+ return (m_historyRoot / filename.str()).lexically_normal();
+}
+
+std::string AutoScreenshotController::BuildTimestampString() {
+ const auto now = std::chrono::system_clock::now();
+ const std::time_t currentTime = std::chrono::system_clock::to_time_t(now);
+ std::tm localTime = {};
+ localtime_s(&localTime, ¤tTime);
+
+ char buffer[32] = {};
+ std::snprintf(
+ buffer,
+ sizeof(buffer),
+ "%04d%02d%02d_%02d%02d%02d",
+ localTime.tm_year + 1900,
+ localTime.tm_mon + 1,
+ localTime.tm_mday,
+ localTime.tm_hour,
+ localTime.tm_min,
+ localTime.tm_sec);
+ return buffer;
+}
+
+std::string AutoScreenshotController::SanitizeReason(std::string_view reason) {
+ std::string sanitized = {};
+ sanitized.reserve(reason.size());
+
+ bool lastWasSeparator = false;
+ for (const unsigned char value : reason) {
+ if (std::isalnum(value)) {
+ sanitized.push_back(static_cast(std::tolower(value)));
+ lastWasSeparator = false;
+ continue;
+ }
+
+ if (!lastWasSeparator) {
+ sanitized.push_back('_');
+ lastWasSeparator = true;
+ }
+ }
+
+ while (!sanitized.empty() && sanitized.front() == '_') {
+ sanitized.erase(sanitized.begin());
+ }
+ while (!sanitized.empty() && sanitized.back() == '_') {
+ sanitized.pop_back();
+ }
+
+ return sanitized.empty() ? "capture" : sanitized;
+}
+
+} // namespace XCEngine::Tests::CoreUI::Host
diff --git a/tests/UI/Core/integration/shared/src/AutoScreenshot.h b/tests/UI/Core/integration/shared/src/AutoScreenshot.h
new file mode 100644
index 00000000..0e887318
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/AutoScreenshot.h
@@ -0,0 +1,52 @@
+#pragma once
+
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+
+#include
+
+#include
+#include
+#include
+#include
+
+namespace XCEngine::Tests::CoreUI::Host {
+
+class NativeRenderer;
+
+class AutoScreenshotController {
+public:
+ void Initialize(const std::filesystem::path& captureRoot);
+ void Shutdown();
+
+ void RequestCapture(std::string reason);
+ void CaptureIfRequested(
+ NativeRenderer& renderer,
+ const ::XCEngine::UI::UIDrawData& drawData,
+ unsigned int width,
+ unsigned int height,
+ bool framePresented);
+
+ bool HasPendingCapture() const;
+ const std::filesystem::path& GetLatestCapturePath() const;
+ const std::string& GetLastCaptureSummary() const;
+ const std::string& GetLastCaptureError() const;
+
+private:
+ std::filesystem::path BuildHistoryCapturePath(std::string_view reason) const;
+
+ static std::string BuildTimestampString();
+ static std::string SanitizeReason(std::string_view reason);
+
+ std::filesystem::path m_captureRoot = {};
+ std::filesystem::path m_historyRoot = {};
+ std::filesystem::path m_latestCapturePath = {};
+ std::string m_pendingReason = {};
+ std::string m_lastCaptureSummary = {};
+ std::string m_lastCaptureError = {};
+ std::uint64_t m_captureCount = 0;
+ bool m_capturePending = false;
+};
+
+} // namespace XCEngine::Tests::CoreUI::Host
diff --git a/tests/UI/Core/integration/shared/src/CoreValidationScenario.cpp b/tests/UI/Core/integration/shared/src/CoreValidationScenario.cpp
new file mode 100644
index 00000000..72191d07
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/CoreValidationScenario.cpp
@@ -0,0 +1,130 @@
+#include "CoreValidationScenario.h"
+
+#include
+
+#ifndef XCENGINE_CORE_UI_TESTS_REPO_ROOT
+#define XCENGINE_CORE_UI_TESTS_REPO_ROOT "."
+#endif
+
+namespace XCEngine::Tests::CoreUI {
+
+namespace {
+
+namespace fs = std::filesystem;
+
+fs::path RepoRootPath() {
+ std::string root = XCENGINE_CORE_UI_TESTS_REPO_ROOT;
+ if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
+ root = root.substr(1u, root.size() - 2u);
+ }
+ return fs::path(root).lexically_normal();
+}
+
+fs::path RepoRelative(const char* relativePath) {
+ return (RepoRootPath() / relativePath).lexically_normal();
+}
+
+const std::array& GetCoreValidationScenarios() {
+ static const std::array scenarios = { {
+ {
+ "core.input.keyboard_focus",
+ UIValidationDomain::Core,
+ "input",
+ "Core Input | Keyboard Focus",
+ RepoRelative("tests/UI/Core/integration/input/keyboard_focus/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/input/keyboard_focus/captures")
+ },
+ {
+ "core.input.pointer_states",
+ UIValidationDomain::Core,
+ "input",
+ "Core Input | Pointer States",
+ RepoRelative("tests/UI/Core/integration/input/pointer_states/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/input/pointer_states/captures")
+ },
+ {
+ "core.input.scroll_view",
+ UIValidationDomain::Core,
+ "input",
+ "Core Input | Scroll View",
+ RepoRelative("tests/UI/Core/integration/input/scroll_view/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/input/scroll_view/captures")
+ },
+ {
+ "core.input.shortcut_scope",
+ UIValidationDomain::Core,
+ "input",
+ "Core Input | Shortcut Scope",
+ RepoRelative("tests/UI/Core/integration/input/shortcut_scope/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/input/shortcut_scope/captures")
+ },
+ {
+ "core.layout.splitter_resize",
+ UIValidationDomain::Core,
+ "layout",
+ "Core Layout | Splitter Resize",
+ RepoRelative("tests/UI/Core/integration/layout/splitter_resize/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/layout/splitter_resize/captures")
+ },
+ {
+ "core.layout.tab_strip_selection",
+ UIValidationDomain::Core,
+ "layout",
+ "Core Layout | TabStrip Selection",
+ RepoRelative("tests/UI/Core/integration/layout/tab_strip_selection/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/layout/tab_strip_selection/captures")
+ },
+ {
+ "core.layout.workspace_compose",
+ UIValidationDomain::Core,
+ "layout",
+ "Core Layout | Workspace Compose",
+ RepoRelative("tests/UI/Core/integration/layout/workspace_compose/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/layout/workspace_compose/captures")
+ },
+ {
+ "core.style.theme_tokens",
+ UIValidationDomain::Core,
+ "style",
+ "Core Style | Theme Tokens",
+ RepoRelative("tests/UI/Core/integration/style/theme_tokens/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/style/theme_tokens/captures")
+ },
+ {
+ "core.text.utf8_focus_surface",
+ UIValidationDomain::Core,
+ "text",
+ "Core Text | UTF-8 Focus Surface",
+ RepoRelative("tests/UI/Core/integration/text/utf8_focus_surface/View.xcui"),
+ RepoRelative("tests/UI/Core/integration/shared/themes/core_validation.xctheme"),
+ RepoRelative("tests/UI/Core/integration/text/utf8_focus_surface/captures")
+ }
+ } };
+ return scenarios;
+}
+
+} // namespace
+
+const CoreValidationScenario& GetDefaultCoreValidationScenario() {
+ return GetCoreValidationScenarios().front();
+}
+
+const CoreValidationScenario* FindCoreValidationScenario(std::string_view id) {
+ for (const CoreValidationScenario& scenario : GetCoreValidationScenarios()) {
+ if (scenario.id == id) {
+ return &scenario;
+ }
+ }
+
+ return nullptr;
+}
+
+} // namespace XCEngine::Tests::CoreUI
diff --git a/tests/UI/Core/integration/shared/src/CoreValidationScenario.h b/tests/UI/Core/integration/shared/src/CoreValidationScenario.h
new file mode 100644
index 00000000..4547ce9b
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/CoreValidationScenario.h
@@ -0,0 +1,26 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace XCEngine::Tests::CoreUI {
+
+enum class UIValidationDomain : unsigned char {
+ Core = 0
+};
+
+struct CoreValidationScenario {
+ std::string id = {};
+ UIValidationDomain domain = UIValidationDomain::Core;
+ std::string categoryId = {};
+ std::string displayName = {};
+ std::filesystem::path documentPath = {};
+ std::filesystem::path themePath = {};
+ std::filesystem::path captureRootPath = {};
+};
+
+const CoreValidationScenario& GetDefaultCoreValidationScenario();
+const CoreValidationScenario* FindCoreValidationScenario(std::string_view id);
+
+} // namespace XCEngine::Tests::CoreUI
diff --git a/tests/UI/Core/integration/shared/src/InputModifierTracker.h b/tests/UI/Core/integration/shared/src/InputModifierTracker.h
new file mode 100644
index 00000000..27c12cda
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/InputModifierTracker.h
@@ -0,0 +1,173 @@
+#pragma once
+
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+
+#include
+
+#include
+
+#include
+#include
+
+namespace XCEngine::Tests::CoreUI::Host {
+
+class InputModifierTracker {
+public:
+ void Reset() {
+ m_leftShift = false;
+ m_rightShift = false;
+ m_leftControl = false;
+ m_rightControl = false;
+ m_leftAlt = false;
+ m_rightAlt = false;
+ m_leftSuper = false;
+ m_rightSuper = false;
+ }
+
+ void SyncFromSystemState() {
+ m_leftShift = (GetKeyState(VK_LSHIFT) & 0x8000) != 0;
+ m_rightShift = (GetKeyState(VK_RSHIFT) & 0x8000) != 0;
+ m_leftControl = (GetKeyState(VK_LCONTROL) & 0x8000) != 0;
+ m_rightControl = (GetKeyState(VK_RCONTROL) & 0x8000) != 0;
+ m_leftAlt = (GetKeyState(VK_LMENU) & 0x8000) != 0;
+ m_rightAlt = (GetKeyState(VK_RMENU) & 0x8000) != 0;
+ m_leftSuper = (GetKeyState(VK_LWIN) & 0x8000) != 0;
+ m_rightSuper = (GetKeyState(VK_RWIN) & 0x8000) != 0;
+ }
+
+ ::XCEngine::UI::UIInputModifiers GetCurrentModifiers() const {
+ return BuildModifiers();
+ }
+
+ ::XCEngine::UI::UIInputModifiers BuildPointerModifiers(std::size_t wParam) const {
+ ::XCEngine::UI::UIInputModifiers modifiers = BuildModifiers();
+ modifiers.shift = modifiers.shift || (wParam & MK_SHIFT) != 0;
+ modifiers.control = modifiers.control || (wParam & MK_CONTROL) != 0;
+ return modifiers;
+ }
+
+ ::XCEngine::UI::UIInputModifiers ApplyKeyMessage(
+ ::XCEngine::UI::UIInputEventType type,
+ WPARAM wParam,
+ LPARAM lParam) {
+ if (type == ::XCEngine::UI::UIInputEventType::KeyDown) {
+ SetModifierState(ResolveModifierKey(wParam, lParam), true);
+ } else if (type == ::XCEngine::UI::UIInputEventType::KeyUp) {
+ SetModifierState(ResolveModifierKey(wParam, lParam), false);
+ }
+
+ return BuildModifiers();
+ }
+
+private:
+ enum class ModifierKey : std::uint8_t {
+ None = 0,
+ LeftShift,
+ RightShift,
+ LeftControl,
+ RightControl,
+ LeftAlt,
+ RightAlt,
+ LeftSuper,
+ RightSuper
+ };
+
+ static bool IsExtendedKey(LPARAM lParam) {
+ return (static_cast(lParam) & 0x01000000u) != 0u;
+ }
+
+ static std::uint32_t ExtractScanCode(LPARAM lParam) {
+ return (static_cast(lParam) >> 16u) & 0xffu;
+ }
+
+ static ModifierKey ResolveModifierKey(WPARAM wParam, LPARAM lParam) {
+ switch (static_cast(wParam)) {
+ case VK_SHIFT: {
+ const UINT shiftVirtualKey = MapVirtualKeyW(ExtractScanCode(lParam), MAPVK_VSC_TO_VK_EX);
+ return shiftVirtualKey == VK_RSHIFT
+ ? ModifierKey::RightShift
+ : ModifierKey::LeftShift;
+ }
+ case VK_LSHIFT:
+ return ModifierKey::LeftShift;
+ case VK_RSHIFT:
+ return ModifierKey::RightShift;
+ case VK_CONTROL:
+ return IsExtendedKey(lParam)
+ ? ModifierKey::RightControl
+ : ModifierKey::LeftControl;
+ case VK_LCONTROL:
+ return ModifierKey::LeftControl;
+ case VK_RCONTROL:
+ return ModifierKey::RightControl;
+ case VK_MENU:
+ return IsExtendedKey(lParam)
+ ? ModifierKey::RightAlt
+ : ModifierKey::LeftAlt;
+ case VK_LMENU:
+ return ModifierKey::LeftAlt;
+ case VK_RMENU:
+ return ModifierKey::RightAlt;
+ case VK_LWIN:
+ return ModifierKey::LeftSuper;
+ case VK_RWIN:
+ return ModifierKey::RightSuper;
+ default:
+ return ModifierKey::None;
+ }
+ }
+
+ void SetModifierState(ModifierKey key, bool pressed) {
+ switch (key) {
+ case ModifierKey::LeftShift:
+ m_leftShift = pressed;
+ break;
+ case ModifierKey::RightShift:
+ m_rightShift = pressed;
+ break;
+ case ModifierKey::LeftControl:
+ m_leftControl = pressed;
+ break;
+ case ModifierKey::RightControl:
+ m_rightControl = pressed;
+ break;
+ case ModifierKey::LeftAlt:
+ m_leftAlt = pressed;
+ break;
+ case ModifierKey::RightAlt:
+ m_rightAlt = pressed;
+ break;
+ case ModifierKey::LeftSuper:
+ m_leftSuper = pressed;
+ break;
+ case ModifierKey::RightSuper:
+ m_rightSuper = pressed;
+ break;
+ case ModifierKey::None:
+ default:
+ break;
+ }
+ }
+
+ ::XCEngine::UI::UIInputModifiers BuildModifiers() const {
+ ::XCEngine::UI::UIInputModifiers modifiers = {};
+ modifiers.shift = m_leftShift || m_rightShift;
+ modifiers.control = m_leftControl || m_rightControl;
+ modifiers.alt = m_leftAlt || m_rightAlt;
+ modifiers.super = m_leftSuper || m_rightSuper;
+ return modifiers;
+ }
+
+ bool m_leftShift = false;
+ bool m_rightShift = false;
+ bool m_leftControl = false;
+ bool m_rightControl = false;
+ bool m_leftAlt = false;
+ bool m_rightAlt = false;
+ bool m_leftSuper = false;
+ bool m_rightSuper = false;
+};
+
+} // namespace XCEngine::Tests::CoreUI::Host
diff --git a/tests/UI/Core/integration/shared/src/NativeRenderer.cpp b/tests/UI/Core/integration/shared/src/NativeRenderer.cpp
new file mode 100644
index 00000000..bec52a5b
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/NativeRenderer.cpp
@@ -0,0 +1,485 @@
+#include "NativeRenderer.h"
+
+#include
+#include
+#include
+
+namespace XCEngine::Tests::CoreUI::Host {
+
+namespace {
+
+D2D1_RECT_F ToD2DRect(const ::XCEngine::UI::UIRect& rect) {
+ return D2D1::RectF(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height);
+}
+
+std::string HrToString(const char* operation, HRESULT hr) {
+ char buffer[128] = {};
+ sprintf_s(buffer, "%s failed with hr=0x%08X.", operation, static_cast(hr));
+ return buffer;
+}
+
+} // namespace
+
+bool NativeRenderer::Initialize(HWND hwnd) {
+ Shutdown();
+
+ if (hwnd == nullptr) {
+ return false;
+ }
+
+ m_hwnd = hwnd;
+ if (FAILED(D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, m_d2dFactory.ReleaseAndGetAddressOf()))) {
+ Shutdown();
+ return false;
+ }
+
+ if (FAILED(DWriteCreateFactory(
+ DWRITE_FACTORY_TYPE_SHARED,
+ __uuidof(IDWriteFactory),
+ reinterpret_cast(m_dwriteFactory.ReleaseAndGetAddressOf())))) {
+ Shutdown();
+ return false;
+ }
+
+ return EnsureRenderTarget();
+}
+
+void NativeRenderer::Shutdown() {
+ m_textFormats.clear();
+ m_solidBrush.Reset();
+ m_renderTarget.Reset();
+ m_wicFactory.Reset();
+ m_dwriteFactory.Reset();
+ m_d2dFactory.Reset();
+ if (m_wicComInitialized) {
+ CoUninitialize();
+ m_wicComInitialized = false;
+ }
+ m_hwnd = nullptr;
+}
+
+void NativeRenderer::Resize(UINT width, UINT height) {
+ if (!m_renderTarget || width == 0 || height == 0) {
+ return;
+ }
+
+ const HRESULT hr = m_renderTarget->Resize(D2D1::SizeU(width, height));
+ if (hr == D2DERR_RECREATE_TARGET) {
+ DiscardRenderTarget();
+ }
+}
+
+bool NativeRenderer::Render(const ::XCEngine::UI::UIDrawData& drawData) {
+ if (!EnsureRenderTarget()) {
+ return false;
+ }
+
+ const bool rendered = RenderToTarget(*m_renderTarget.Get(), *m_solidBrush.Get(), drawData);
+ const HRESULT hr = m_renderTarget->EndDraw();
+ if (hr == D2DERR_RECREATE_TARGET) {
+ DiscardRenderTarget();
+ return false;
+ }
+
+ return rendered && SUCCEEDED(hr);
+}
+
+bool NativeRenderer::CaptureToPng(
+ const ::XCEngine::UI::UIDrawData& drawData,
+ UINT width,
+ UINT height,
+ const std::filesystem::path& outputPath,
+ std::string& outError) {
+ outError.clear();
+ if (width == 0 || height == 0) {
+ outError = "CaptureToPng rejected an empty render size.";
+ return false;
+ }
+
+ if (!m_d2dFactory || !m_dwriteFactory) {
+ outError = "CaptureToPng requires an initialized NativeRenderer.";
+ return false;
+ }
+
+ if (!EnsureWicFactory(outError)) {
+ return false;
+ }
+
+ std::error_code errorCode = {};
+ std::filesystem::create_directories(outputPath.parent_path(), errorCode);
+ if (errorCode) {
+ outError = "Failed to create screenshot directory: " + outputPath.parent_path().string();
+ return false;
+ }
+
+ Microsoft::WRL::ComPtr bitmap;
+ HRESULT hr = m_wicFactory->CreateBitmap(
+ width,
+ height,
+ GUID_WICPixelFormat32bppPBGRA,
+ WICBitmapCacheOnLoad,
+ bitmap.ReleaseAndGetAddressOf());
+ if (FAILED(hr)) {
+ outError = HrToString("IWICImagingFactory::CreateBitmap", hr);
+ return false;
+ }
+
+ const D2D1_RENDER_TARGET_PROPERTIES renderTargetProperties = D2D1::RenderTargetProperties(
+ D2D1_RENDER_TARGET_TYPE_DEFAULT,
+ D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED));
+
+ Microsoft::WRL::ComPtr offscreenRenderTarget;
+ hr = m_d2dFactory->CreateWicBitmapRenderTarget(
+ bitmap.Get(),
+ renderTargetProperties,
+ offscreenRenderTarget.ReleaseAndGetAddressOf());
+ if (FAILED(hr)) {
+ outError = HrToString("ID2D1Factory::CreateWicBitmapRenderTarget", hr);
+ return false;
+ }
+
+ Microsoft::WRL::ComPtr offscreenBrush;
+ hr = offscreenRenderTarget->CreateSolidColorBrush(
+ D2D1::ColorF(1.0f, 1.0f, 1.0f, 1.0f),
+ offscreenBrush.ReleaseAndGetAddressOf());
+ if (FAILED(hr)) {
+ outError = HrToString("ID2D1RenderTarget::CreateSolidColorBrush", hr);
+ return false;
+ }
+
+ const bool rendered = RenderToTarget(*offscreenRenderTarget.Get(), *offscreenBrush.Get(), drawData);
+ hr = offscreenRenderTarget->EndDraw();
+ if (!rendered || FAILED(hr)) {
+ outError = HrToString("ID2D1RenderTarget::EndDraw", hr);
+ return false;
+ }
+
+ const std::wstring wideOutputPath = outputPath.wstring();
+ Microsoft::WRL::ComPtr stream;
+ hr = m_wicFactory->CreateStream(stream.ReleaseAndGetAddressOf());
+ if (FAILED(hr)) {
+ outError = HrToString("IWICImagingFactory::CreateStream", hr);
+ return false;
+ }
+
+ hr = stream->InitializeFromFilename(wideOutputPath.c_str(), GENERIC_WRITE);
+ if (FAILED(hr)) {
+ outError = HrToString("IWICStream::InitializeFromFilename", hr);
+ return false;
+ }
+
+ Microsoft::WRL::ComPtr encoder;
+ hr = m_wicFactory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.ReleaseAndGetAddressOf());
+ if (FAILED(hr)) {
+ outError = HrToString("IWICImagingFactory::CreateEncoder", hr);
+ return false;
+ }
+
+ hr = encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache);
+ if (FAILED(hr)) {
+ outError = HrToString("IWICBitmapEncoder::Initialize", hr);
+ return false;
+ }
+
+ Microsoft::WRL::ComPtr frame;
+ Microsoft::WRL::ComPtr propertyBag;
+ hr = encoder->CreateNewFrame(frame.ReleaseAndGetAddressOf(), propertyBag.ReleaseAndGetAddressOf());
+ if (FAILED(hr)) {
+ outError = HrToString("IWICBitmapEncoder::CreateNewFrame", hr);
+ return false;
+ }
+
+ hr = frame->Initialize(propertyBag.Get());
+ if (FAILED(hr)) {
+ outError = HrToString("IWICBitmapFrameEncode::Initialize", hr);
+ return false;
+ }
+
+ hr = frame->SetSize(width, height);
+ if (FAILED(hr)) {
+ outError = HrToString("IWICBitmapFrameEncode::SetSize", hr);
+ return false;
+ }
+
+ WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat32bppPBGRA;
+ hr = frame->SetPixelFormat(&pixelFormat);
+ if (FAILED(hr)) {
+ outError = HrToString("IWICBitmapFrameEncode::SetPixelFormat", hr);
+ return false;
+ }
+
+ hr = frame->WriteSource(bitmap.Get(), nullptr);
+ if (FAILED(hr)) {
+ outError = HrToString("IWICBitmapFrameEncode::WriteSource", hr);
+ return false;
+ }
+
+ hr = frame->Commit();
+ if (FAILED(hr)) {
+ outError = HrToString("IWICBitmapFrameEncode::Commit", hr);
+ return false;
+ }
+
+ hr = encoder->Commit();
+ if (FAILED(hr)) {
+ outError = HrToString("IWICBitmapEncoder::Commit", hr);
+ return false;
+ }
+
+ return true;
+}
+
+bool NativeRenderer::EnsureRenderTarget() {
+ if (!m_hwnd || !m_d2dFactory || !m_dwriteFactory) {
+ return false;
+ }
+
+ return CreateDeviceResources();
+}
+
+bool NativeRenderer::EnsureWicFactory(std::string& outError) {
+ outError.clear();
+ if (m_wicFactory) {
+ return true;
+ }
+
+ const HRESULT initHr = CoInitializeEx(nullptr, COINIT_MULTITHREADED);
+ if (FAILED(initHr) && initHr != RPC_E_CHANGED_MODE) {
+ outError = HrToString("CoInitializeEx", initHr);
+ return false;
+ }
+ if (SUCCEEDED(initHr)) {
+ m_wicComInitialized = true;
+ }
+
+ const HRESULT factoryHr = CoCreateInstance(
+ CLSID_WICImagingFactory,
+ nullptr,
+ CLSCTX_INPROC_SERVER,
+ IID_PPV_ARGS(m_wicFactory.ReleaseAndGetAddressOf()));
+ if (FAILED(factoryHr)) {
+ outError = HrToString("CoCreateInstance(CLSID_WICImagingFactory)", factoryHr);
+ return false;
+ }
+
+ return true;
+}
+
+void NativeRenderer::DiscardRenderTarget() {
+ m_solidBrush.Reset();
+ m_renderTarget.Reset();
+}
+
+bool NativeRenderer::CreateDeviceResources() {
+ if (m_renderTarget) {
+ return true;
+ }
+
+ RECT clientRect = {};
+ GetClientRect(m_hwnd, &clientRect);
+ const UINT width = static_cast((std::max)(clientRect.right - clientRect.left, 1L));
+ const UINT height = static_cast((std::max)(clientRect.bottom - clientRect.top, 1L));
+
+ const D2D1_RENDER_TARGET_PROPERTIES renderTargetProps = D2D1::RenderTargetProperties();
+ const D2D1_HWND_RENDER_TARGET_PROPERTIES hwndProps = D2D1::HwndRenderTargetProperties(
+ m_hwnd,
+ D2D1::SizeU(width, height));
+
+ if (FAILED(m_d2dFactory->CreateHwndRenderTarget(
+ renderTargetProps,
+ hwndProps,
+ m_renderTarget.ReleaseAndGetAddressOf()))) {
+ return false;
+ }
+
+ if (FAILED(m_renderTarget->CreateSolidColorBrush(
+ D2D1::ColorF(1.0f, 1.0f, 1.0f, 1.0f),
+ m_solidBrush.ReleaseAndGetAddressOf()))) {
+ DiscardRenderTarget();
+ return false;
+ }
+
+ m_renderTarget->SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE);
+ return true;
+}
+
+bool NativeRenderer::RenderToTarget(
+ ID2D1RenderTarget& renderTarget,
+ ID2D1SolidColorBrush& solidBrush,
+ const ::XCEngine::UI::UIDrawData& drawData) {
+ renderTarget.SetTextAntialiasMode(D2D1_TEXT_ANTIALIAS_MODE_GRAYSCALE);
+ renderTarget.BeginDraw();
+ renderTarget.Clear(D2D1::ColorF(0.04f, 0.05f, 0.06f, 1.0f));
+
+ std::vector clipStack = {};
+ for (const ::XCEngine::UI::UIDrawList& drawList : drawData.GetDrawLists()) {
+ for (const ::XCEngine::UI::UIDrawCommand& command : drawList.GetCommands()) {
+ RenderCommand(renderTarget, solidBrush, command, clipStack);
+ }
+ }
+
+ while (!clipStack.empty()) {
+ renderTarget.PopAxisAlignedClip();
+ clipStack.pop_back();
+ }
+
+ return true;
+}
+
+void NativeRenderer::RenderCommand(
+ ID2D1RenderTarget& renderTarget,
+ ID2D1SolidColorBrush& solidBrush,
+ const ::XCEngine::UI::UIDrawCommand& command,
+ std::vector& clipStack) {
+ solidBrush.SetColor(ToD2DColor(command.color));
+
+ switch (command.type) {
+ case ::XCEngine::UI::UIDrawCommandType::FilledRect: {
+ const D2D1_RECT_F rect = ToD2DRect(command.rect);
+ if (command.rounding > 0.0f) {
+ renderTarget.FillRoundedRectangle(
+ D2D1::RoundedRect(rect, command.rounding, command.rounding),
+ &solidBrush);
+ } else {
+ renderTarget.FillRectangle(rect, &solidBrush);
+ }
+ break;
+ }
+ case ::XCEngine::UI::UIDrawCommandType::RectOutline: {
+ const D2D1_RECT_F rect = ToD2DRect(command.rect);
+ const float thickness = command.thickness > 0.0f ? command.thickness : 1.0f;
+ if (command.rounding > 0.0f) {
+ renderTarget.DrawRoundedRectangle(
+ D2D1::RoundedRect(rect, command.rounding, command.rounding),
+ &solidBrush,
+ thickness);
+ } else {
+ renderTarget.DrawRectangle(rect, &solidBrush, thickness);
+ }
+ break;
+ }
+ case ::XCEngine::UI::UIDrawCommandType::Text: {
+ if (command.text.empty()) {
+ break;
+ }
+
+ const float fontSize = command.fontSize > 0.0f ? command.fontSize : 16.0f;
+ IDWriteTextFormat* textFormat = GetTextFormat(fontSize);
+ if (textFormat == nullptr) {
+ break;
+ }
+
+ const std::wstring text = Utf8ToWide(command.text);
+ if (text.empty()) {
+ break;
+ }
+
+ const D2D1_SIZE_F targetSize = renderTarget.GetSize();
+ const D2D1_RECT_F layoutRect = D2D1::RectF(
+ command.position.x,
+ command.position.y,
+ targetSize.width,
+ command.position.y + fontSize * 1.8f);
+ renderTarget.DrawTextW(
+ text.c_str(),
+ static_cast(text.size()),
+ textFormat,
+ layoutRect,
+ &solidBrush,
+ D2D1_DRAW_TEXT_OPTIONS_CLIP,
+ DWRITE_MEASURING_MODE_NATURAL);
+ break;
+ }
+ case ::XCEngine::UI::UIDrawCommandType::Image: {
+ if (!command.texture.IsValid()) {
+ break;
+ }
+
+ const D2D1_RECT_F rect = ToD2DRect(command.rect);
+ renderTarget.DrawRectangle(rect, &solidBrush, 1.0f);
+ break;
+ }
+ case ::XCEngine::UI::UIDrawCommandType::PushClipRect: {
+ const D2D1_RECT_F rect = ToD2DRect(command.rect);
+ renderTarget.PushAxisAlignedClip(rect, D2D1_ANTIALIAS_MODE_PER_PRIMITIVE);
+ clipStack.push_back(rect);
+ break;
+ }
+ case ::XCEngine::UI::UIDrawCommandType::PopClipRect: {
+ if (!clipStack.empty()) {
+ renderTarget.PopAxisAlignedClip();
+ clipStack.pop_back();
+ }
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+IDWriteTextFormat* NativeRenderer::GetTextFormat(float fontSize) {
+ if (!m_dwriteFactory) {
+ return nullptr;
+ }
+
+ const int key = static_cast(std::lround(fontSize * 10.0f));
+ const auto found = m_textFormats.find(key);
+ if (found != m_textFormats.end()) {
+ return found->second.Get();
+ }
+
+ Microsoft::WRL::ComPtr textFormat;
+ const HRESULT hr = m_dwriteFactory->CreateTextFormat(
+ L"Segoe UI",
+ nullptr,
+ DWRITE_FONT_WEIGHT_REGULAR,
+ DWRITE_FONT_STYLE_NORMAL,
+ DWRITE_FONT_STRETCH_NORMAL,
+ fontSize,
+ L"",
+ textFormat.ReleaseAndGetAddressOf());
+ if (FAILED(hr)) {
+ return nullptr;
+ }
+
+ textFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);
+ textFormat->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_NEAR);
+ textFormat->SetWordWrapping(DWRITE_WORD_WRAPPING_NO_WRAP);
+
+ IDWriteTextFormat* result = textFormat.Get();
+ m_textFormats.emplace(key, std::move(textFormat));
+ return result;
+}
+
+D2D1_COLOR_F NativeRenderer::ToD2DColor(const ::XCEngine::UI::UIColor& color) {
+ return D2D1::ColorF(color.r, color.g, color.b, color.a);
+}
+
+std::wstring NativeRenderer::Utf8ToWide(std::string_view text) {
+ if (text.empty()) {
+ return {};
+ }
+
+ const int sizeNeeded = MultiByteToWideChar(
+ CP_UTF8,
+ 0,
+ text.data(),
+ static_cast(text.size()),
+ nullptr,
+ 0);
+ if (sizeNeeded <= 0) {
+ return {};
+ }
+
+ std::wstring wideText(static_cast(sizeNeeded), L'\0');
+ MultiByteToWideChar(
+ CP_UTF8,
+ 0,
+ text.data(),
+ static_cast(text.size()),
+ wideText.data(),
+ sizeNeeded);
+ return wideText;
+}
+
+} // namespace XCEngine::Tests::CoreUI::Host
diff --git a/tests/UI/Core/integration/shared/src/NativeRenderer.h b/tests/UI/Core/integration/shared/src/NativeRenderer.h
new file mode 100644
index 00000000..21e63658
--- /dev/null
+++ b/tests/UI/Core/integration/shared/src/NativeRenderer.h
@@ -0,0 +1,65 @@
+#pragma once
+
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+namespace XCEngine::Tests::CoreUI::Host {
+
+class NativeRenderer {
+public:
+ bool Initialize(HWND hwnd);
+ void Shutdown();
+ void Resize(UINT width, UINT height);
+ bool Render(const ::XCEngine::UI::UIDrawData& drawData);
+ bool CaptureToPng(
+ const ::XCEngine::UI::UIDrawData& drawData,
+ UINT width,
+ UINT height,
+ const std::filesystem::path& outputPath,
+ std::string& outError);
+
+private:
+ bool EnsureRenderTarget();
+ bool EnsureWicFactory(std::string& outError);
+ void DiscardRenderTarget();
+ bool CreateDeviceResources();
+ bool RenderToTarget(
+ ID2D1RenderTarget& renderTarget,
+ ID2D1SolidColorBrush& solidBrush,
+ const ::XCEngine::UI::UIDrawData& drawData);
+ void RenderCommand(
+ ID2D1RenderTarget& renderTarget,
+ ID2D1SolidColorBrush& solidBrush,
+ const ::XCEngine::UI::UIDrawCommand& command,
+ std::vector& clipStack);
+
+ IDWriteTextFormat* GetTextFormat(float fontSize);
+ static D2D1_COLOR_F ToD2DColor(const ::XCEngine::UI::UIColor& color);
+ static std::wstring Utf8ToWide(std::string_view text);
+
+ HWND m_hwnd = nullptr;
+ Microsoft::WRL::ComPtr m_d2dFactory;
+ Microsoft::WRL::ComPtr m_dwriteFactory;
+ Microsoft::WRL::ComPtr m_wicFactory;
+ Microsoft::WRL::ComPtr m_renderTarget;
+ Microsoft::WRL::ComPtr m_solidBrush;
+ std::unordered_map> m_textFormats;
+ bool m_wicComInitialized = false;
+};
+
+} // namespace XCEngine::Tests::CoreUI::Host
diff --git a/tests/UI/Core/integration/shared/themes/core_validation.xctheme b/tests/UI/Core/integration/shared/themes/core_validation.xctheme
new file mode 100644
index 00000000..574558be
--- /dev/null
+++ b/tests/UI/Core/integration/shared/themes/core_validation.xctheme
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/UI/Core/integration/style/CMakeLists.txt b/tests/UI/Core/integration/style/CMakeLists.txt
new file mode 100644
index 00000000..f34fdc3e
--- /dev/null
+++ b/tests/UI/Core/integration/style/CMakeLists.txt
@@ -0,0 +1,6 @@
+add_subdirectory(theme_tokens)
+
+add_custom_target(core_ui_style_integration_tests
+ DEPENDS
+ core_ui_style_theme_tokens_validation
+)
diff --git a/tests/UI/Core/integration/style/theme_tokens/CMakeLists.txt b/tests/UI/Core/integration/style/theme_tokens/CMakeLists.txt
new file mode 100644
index 00000000..39656707
--- /dev/null
+++ b/tests/UI/Core/integration/style/theme_tokens/CMakeLists.txt
@@ -0,0 +1,35 @@
+set(CORE_UI_STYLE_THEME_TOKENS_RESOURCES
+ View.xcui
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
+)
+
+add_executable(core_ui_style_theme_tokens_validation WIN32
+ main.cpp
+ ${CORE_UI_STYLE_THEME_TOKENS_RESOURCES}
+)
+
+target_include_directories(core_ui_style_theme_tokens_validation PRIVATE
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_style_theme_tokens_validation PRIVATE
+ UNICODE
+ _UNICODE
+)
+
+if(MSVC)
+ target_compile_options(core_ui_style_theme_tokens_validation PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_style_theme_tokens_validation PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_style_theme_tokens_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_style_theme_tokens_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreStyleThemeTokensValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Core/integration/style/theme_tokens/View.xcui b/tests/UI/Core/integration/style/theme_tokens/View.xcui
new file mode 100644
index 00000000..e411b1dd
--- /dev/null
+++ b/tests/UI/Core/integration/style/theme_tokens/View.xcui
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/UI/Core/integration/style/theme_tokens/captures/.gitkeep b/tests/UI/Core/integration/style/theme_tokens/captures/.gitkeep
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/tests/UI/Core/integration/style/theme_tokens/captures/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/tests/UI/Core/integration/style/theme_tokens/main.cpp b/tests/UI/Core/integration/style/theme_tokens/main.cpp
new file mode 100644
index 00000000..94be4c1a
--- /dev/null
+++ b/tests/UI/Core/integration/style/theme_tokens/main.cpp
@@ -0,0 +1,8 @@
+#include "Application.h"
+
+int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
+ return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
+ hInstance,
+ nCmdShow,
+ "core.style.theme_tokens");
+}
diff --git a/tests/UI/Core/integration/text/CMakeLists.txt b/tests/UI/Core/integration/text/CMakeLists.txt
new file mode 100644
index 00000000..c3a8d165
--- /dev/null
+++ b/tests/UI/Core/integration/text/CMakeLists.txt
@@ -0,0 +1,6 @@
+add_subdirectory(utf8_focus_surface)
+
+add_custom_target(core_ui_text_integration_tests
+ DEPENDS
+ core_ui_text_utf8_focus_surface_validation
+)
diff --git a/tests/UI/Core/integration/text/utf8_focus_surface/CMakeLists.txt b/tests/UI/Core/integration/text/utf8_focus_surface/CMakeLists.txt
new file mode 100644
index 00000000..b04f9ff7
--- /dev/null
+++ b/tests/UI/Core/integration/text/utf8_focus_surface/CMakeLists.txt
@@ -0,0 +1,35 @@
+set(CORE_UI_TEXT_UTF8_FOCUS_SURFACE_RESOURCES
+ View.xcui
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/themes/core_validation.xctheme
+)
+
+add_executable(core_ui_text_utf8_focus_surface_validation WIN32
+ main.cpp
+ ${CORE_UI_TEXT_UTF8_FOCUS_SURFACE_RESOURCES}
+)
+
+target_include_directories(core_ui_text_utf8_focus_surface_validation PRIVATE
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
+ ${CMAKE_SOURCE_DIR}/engine/include
+)
+
+target_compile_definitions(core_ui_text_utf8_focus_surface_validation PRIVATE
+ UNICODE
+ _UNICODE
+)
+
+if(MSVC)
+ target_compile_options(core_ui_text_utf8_focus_surface_validation PRIVATE /utf-8 /FS)
+ set_property(TARGET core_ui_text_utf8_focus_surface_validation PROPERTY
+ MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>DLL")
+endif()
+
+target_link_libraries(core_ui_text_utf8_focus_surface_validation PRIVATE
+ core_ui_integration_host
+)
+
+set_target_properties(core_ui_text_utf8_focus_surface_validation PROPERTIES
+ OUTPUT_NAME "XCUICoreTextUtf8FocusSurfaceValidation"
+)
+
+source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui)
diff --git a/tests/UI/Core/integration/text/utf8_focus_surface/View.xcui b/tests/UI/Core/integration/text/utf8_focus_surface/View.xcui
new file mode 100644
index 00000000..031ea0b7
--- /dev/null
+++ b/tests/UI/Core/integration/text/utf8_focus_surface/View.xcui
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/UI/Core/integration/text/utf8_focus_surface/captures/.gitkeep b/tests/UI/Core/integration/text/utf8_focus_surface/captures/.gitkeep
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/tests/UI/Core/integration/text/utf8_focus_surface/captures/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/tests/UI/Core/integration/text/utf8_focus_surface/main.cpp b/tests/UI/Core/integration/text/utf8_focus_surface/main.cpp
new file mode 100644
index 00000000..56afe319
--- /dev/null
+++ b/tests/UI/Core/integration/text/utf8_focus_surface/main.cpp
@@ -0,0 +1,8 @@
+#include "Application.h"
+
+int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
+ return XCEngine::Tests::CoreUI::RunCoreUIValidationApp(
+ hInstance,
+ nCmdShow,
+ "core.text.utf8_focus_surface");
+}
diff --git a/tests/UI/Core/unit/CMakeLists.txt b/tests/UI/Core/unit/CMakeLists.txt
index 0e305236..a078ce6b 100644
--- a/tests/UI/Core/unit/CMakeLists.txt
+++ b/tests/UI/Core/unit/CMakeLists.txt
@@ -1,21 +1,22 @@
set(CORE_UI_TEST_SOURCES
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_core_validation_registry.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_input_modifier_tracker.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_layout_engine.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_core.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_expansion_model.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_flat_hierarchy_helpers.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_input_dispatcher.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_keyboard_navigation_model.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_property_edit_model.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_selection_model.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_style_system.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_shortcut_scope.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_interaction.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_model.cpp
- # Migration bridge: legacy XCUI unit coverage still lives under tests/Core/UI
- # until it is moved into tests/UI/Core/unit without changing behavior.
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_core.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_expansion_model.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_flat_hierarchy_helpers.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_input_dispatcher.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_keyboard_navigation_model.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_property_edit_model.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_layout_engine.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_selection_model.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_text_editing.cpp
- ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_text_input_controller.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_text_editing.cpp
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_text_input_controller.cpp
)
add_executable(core_ui_tests ${CORE_UI_TEST_SOURCES})
@@ -28,6 +29,7 @@ endif()
target_link_libraries(core_ui_tests
PRIVATE
+ core_ui_validation_registry
XCEngine
GTest::gtest
GTest::gtest_main
@@ -35,6 +37,7 @@ target_link_libraries(core_ui_tests
target_include_directories(core_ui_tests PRIVATE
${CMAKE_SOURCE_DIR}/engine/include
+ ${CMAKE_SOURCE_DIR}/tests/UI/Core/integration/shared/src
${CMAKE_SOURCE_DIR}/tests/Fixtures
)
diff --git a/tests/UI/Core/unit/test_core_validation_registry.cpp b/tests/UI/Core/unit/test_core_validation_registry.cpp
new file mode 100644
index 00000000..c5defe4d
--- /dev/null
+++ b/tests/UI/Core/unit/test_core_validation_registry.cpp
@@ -0,0 +1,78 @@
+#include
+
+#include "CoreValidationScenario.h"
+
+#include
+
+namespace {
+
+using XCEngine::Tests::CoreUI::FindCoreValidationScenario;
+using XCEngine::Tests::CoreUI::GetDefaultCoreValidationScenario;
+using XCEngine::Tests::CoreUI::UIValidationDomain;
+
+} // namespace
+
+TEST(CoreValidationRegistryTest, KnownCoreValidationScenariosResolveToExistingResources) {
+ const auto* pointerScenario = FindCoreValidationScenario("core.input.pointer_states");
+ const auto* keyboardScenario = FindCoreValidationScenario("core.input.keyboard_focus");
+ const auto* scrollScenario = FindCoreValidationScenario("core.input.scroll_view");
+ const auto* shortcutScenario = FindCoreValidationScenario("core.input.shortcut_scope");
+ const auto* splitterScenario = FindCoreValidationScenario("core.layout.splitter_resize");
+ const auto* tabStripScenario = FindCoreValidationScenario("core.layout.tab_strip_selection");
+ const auto* workspaceScenario = FindCoreValidationScenario("core.layout.workspace_compose");
+ const auto* styleScenario = FindCoreValidationScenario("core.style.theme_tokens");
+ const auto* textScenario = FindCoreValidationScenario("core.text.utf8_focus_surface");
+
+ ASSERT_NE(pointerScenario, nullptr);
+ ASSERT_NE(keyboardScenario, nullptr);
+ ASSERT_NE(scrollScenario, nullptr);
+ ASSERT_NE(shortcutScenario, nullptr);
+ ASSERT_NE(splitterScenario, nullptr);
+ ASSERT_NE(tabStripScenario, nullptr);
+ ASSERT_NE(workspaceScenario, nullptr);
+ ASSERT_NE(styleScenario, nullptr);
+ ASSERT_NE(textScenario, nullptr);
+ EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(scrollScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(tabStripScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(workspaceScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(styleScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(textScenario->domain, UIValidationDomain::Core);
+ EXPECT_EQ(pointerScenario->categoryId, "input");
+ EXPECT_EQ(keyboardScenario->categoryId, "input");
+ EXPECT_EQ(scrollScenario->categoryId, "input");
+ EXPECT_EQ(shortcutScenario->categoryId, "input");
+ EXPECT_EQ(splitterScenario->categoryId, "layout");
+ EXPECT_EQ(tabStripScenario->categoryId, "layout");
+ EXPECT_EQ(workspaceScenario->categoryId, "layout");
+ EXPECT_EQ(styleScenario->categoryId, "style");
+ EXPECT_EQ(textScenario->categoryId, "text");
+ EXPECT_TRUE(std::filesystem::exists(pointerScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath));
+ EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(keyboardScenario->themePath));
+ EXPECT_TRUE(std::filesystem::exists(scrollScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(scrollScenario->themePath));
+ EXPECT_TRUE(std::filesystem::exists(shortcutScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(shortcutScenario->themePath));
+ EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(splitterScenario->themePath));
+ EXPECT_TRUE(std::filesystem::exists(tabStripScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(tabStripScenario->themePath));
+ EXPECT_TRUE(std::filesystem::exists(workspaceScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(workspaceScenario->themePath));
+ EXPECT_TRUE(std::filesystem::exists(styleScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(styleScenario->themePath));
+ EXPECT_TRUE(std::filesystem::exists(textScenario->documentPath));
+ EXPECT_TRUE(std::filesystem::exists(textScenario->themePath));
+}
+
+TEST(CoreValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) {
+ const auto& scenario = GetDefaultCoreValidationScenario();
+ EXPECT_EQ(scenario.id, "core.input.keyboard_focus");
+ EXPECT_EQ(scenario.domain, UIValidationDomain::Core);
+ EXPECT_TRUE(std::filesystem::exists(scenario.documentPath));
+}
diff --git a/tests/UI/Core/unit/test_input_modifier_tracker.cpp b/tests/UI/Core/unit/test_input_modifier_tracker.cpp
new file mode 100644
index 00000000..f03123c3
--- /dev/null
+++ b/tests/UI/Core/unit/test_input_modifier_tracker.cpp
@@ -0,0 +1,90 @@
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+
+#include
+
+#include "InputModifierTracker.h"
+
+#include
+
+#include
+
+namespace {
+
+using XCEngine::Tests::CoreUI::Host::InputModifierTracker;
+using XCEngine::UI::UIInputEventType;
+
+TEST(CoreInputModifierTrackerTest, ControlStatePersistsAcrossChordKeyDownAndClearsOnKeyUp) {
+ InputModifierTracker tracker = {};
+
+ const auto ctrlDown = tracker.ApplyKeyMessage(
+ UIInputEventType::KeyDown,
+ VK_CONTROL,
+ 0x001D0001);
+ EXPECT_TRUE(ctrlDown.control);
+ EXPECT_FALSE(ctrlDown.shift);
+ EXPECT_FALSE(ctrlDown.alt);
+
+ const auto chordKeyDown = tracker.ApplyKeyMessage(
+ UIInputEventType::KeyDown,
+ 'P',
+ 0x00190001);
+ EXPECT_TRUE(chordKeyDown.control);
+
+ const auto ctrlUp = tracker.ApplyKeyMessage(
+ UIInputEventType::KeyUp,
+ VK_CONTROL,
+ static_cast(0xC01D0001u));
+ EXPECT_FALSE(ctrlUp.control);
+
+ const auto nextKeyDown = tracker.ApplyKeyMessage(
+ UIInputEventType::KeyDown,
+ 'P',
+ 0x00190001);
+ EXPECT_FALSE(nextKeyDown.control);
+}
+
+TEST(CoreInputModifierTrackerTest, PointerModifiersMergeMouseFlagsWithTrackedKeyboardState) {
+ InputModifierTracker tracker = {};
+ tracker.ApplyKeyMessage(
+ UIInputEventType::KeyDown,
+ VK_MENU,
+ 0x00380001);
+
+ const auto modifiers = tracker.BuildPointerModifiers(MK_SHIFT);
+ EXPECT_TRUE(modifiers.shift);
+ EXPECT_TRUE(modifiers.alt);
+ EXPECT_FALSE(modifiers.control);
+ EXPECT_FALSE(modifiers.super);
+}
+
+TEST(CoreInputModifierTrackerTest, RightControlIsTrackedIndependentlyFromLeftControl) {
+ InputModifierTracker tracker = {};
+
+ tracker.ApplyKeyMessage(
+ UIInputEventType::KeyDown,
+ VK_CONTROL,
+ static_cast(0x011D0001u));
+ EXPECT_TRUE(tracker.GetCurrentModifiers().control);
+
+ tracker.ApplyKeyMessage(
+ UIInputEventType::KeyDown,
+ VK_CONTROL,
+ 0x001D0001);
+ EXPECT_TRUE(tracker.GetCurrentModifiers().control);
+
+ tracker.ApplyKeyMessage(
+ UIInputEventType::KeyUp,
+ VK_CONTROL,
+ static_cast(0xC11D0001u));
+ EXPECT_TRUE(tracker.GetCurrentModifiers().control);
+
+ tracker.ApplyKeyMessage(
+ UIInputEventType::KeyUp,
+ VK_CONTROL,
+ static_cast(0xC01D0001u));
+ EXPECT_FALSE(tracker.GetCurrentModifiers().control);
+}
+
+} // namespace
diff --git a/tests/UI/Core/unit/test_layout_engine.cpp b/tests/UI/Core/unit/test_layout_engine.cpp
new file mode 100644
index 00000000..12d0b218
--- /dev/null
+++ b/tests/UI/Core/unit/test_layout_engine.cpp
@@ -0,0 +1,130 @@
+#include