feat(xcui): advance core and editor validation flow

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

View File

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

View File

@@ -1,8 +1,24 @@
# Core UI Integration Notes
# Core UI Integration Validation
The core XCUI lane currently validates shared primitives through automated unit tests.
This directory contains the manual XCUI validation system for shared Core primitives.
Interactive validation belongs to:
Structure:
- `tests/UI/Runtime/integration/` for game/runtime UI
- `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
```

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
<View
name="CoreInputKeyboardFocus"
theme="../../shared/themes/core_validation.xctheme">
<Column padding="24" gap="16">
<Card
title="Core Validation | Keyboard Focus"
subtitle="当前批次Tab 焦点遍历 | Enter / Space 激活"
tone="accent"
height="90">
<Column gap="8">
<Text text="这是 Core 层验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查键盘焦点和激活,不混入上层业务面板。" />
</Column>
</Card>
<Card title="Keyboard Focus" subtitle="tab focus active" height="214">
<Column gap="12">
<Text text="只检查下面三个可聚焦按钮和右下角状态叠层。" />
<Row gap="12">
<Button id="focus-first" text="First Focus" />
<Button id="focus-second" text="Second Focus" />
<Button id="focus-third" text="Third Focus" />
</Row>
<Text text="1. 按 Tabfocus 应依次切到 First / Second / Third。" />
<Text text="2. 按 Shift+Tabfocus 应反向切换。" />
<Text text="3. focus 停在任一按钮后,按 Enter 或 Spaceactive 应出现;松开后 active 清空。" />
</Column>
</Card>
</Column>
</View>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
<View
name="CoreInputPointerStates"
theme="../../shared/themes/core_validation.xctheme">
<Column padding="24" gap="16">
<Card
title="Core Validation | Pointer States"
subtitle="当前批次:鼠标 hover / focus / active / capture"
tone="accent"
height="90">
<Column gap="8">
<Text text="这是 Core 层验证场景,不承载 runtime 游戏 UI。" />
<Text text="这一轮只检查鼠标输入状态,不混入别的控件实验。" />
</Column>
</Card>
<Card title="Pointer Input" subtitle="hover focus active capture" height="196">
<Column gap="12">
<Text text="这一轮只需要检查下面这三个按钮。" />
<Row gap="12">
<Button id="input-hover" text="Hover / Focus" />
<Button id="input-capture" text="Pointer Capture" capturePointer="true" />
<Button id="input-route" text="Route Target" />
</Row>
<Text text="1. 鼠标移到左侧按钮hover 应变化focus 保持空。" />
<Text text="2. 按住中间按钮focus、active、capture 都应留在中间。" />
<Text text="3. 拖到右侧再松开hover 移到右侧capture 清空focus 仍留中间。" />
</Column>
</Card>
</Column>
</View>

View File

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

View File

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

View File

@@ -0,0 +1,61 @@
<View
name="CoreInputScrollView"
theme="../../shared/themes/core_validation.xctheme">
<Column padding="20" gap="12">
<Card
title="功能ScrollView 滚动 / clip / overflow"
subtitle="只检查滚轮滚动、裁剪、overflow 与 target 路由,不检查业务面板"
tone="accent"
height="118">
<Column gap="6">
<Text text="1. 把鼠标放到下方日志区内滚动滚轮:内容应上下移动,右下角 Scroll target 应落到 validation-scroll。" />
<Text text="2. 连续向下滚到末尾再继续滚Offset 应被 clampResult 应显示 Scroll delta clamped to current offset。" />
<Text text="3. 把鼠标移到日志区外再滚动日志位置不应变化Result 应显示 No hovered ScrollView。" />
<Text text="4. 这个场景只验证 ScrollView 基础能力,不验证上层业务面板。" />
</Column>
</Card>
<Card
title="Scrollable Log"
subtitle="wheel inside this viewport"
height="fill">
<ScrollView id="validation-scroll" height="fill">
<Column gap="8">
<Text text="Line 01 - Scroll validation log" />
<Text text="Line 02 - Scroll validation log" />
<Text text="Line 03 - Scroll validation log" />
<Text text="Line 04 - Scroll validation log" />
<Text text="Line 05 - Scroll validation log" />
<Text text="Line 06 - Scroll validation log" />
<Text text="Line 07 - Scroll validation log" />
<Text text="Line 08 - Scroll validation log" />
<Text text="Line 09 - Scroll validation log" />
<Text text="Line 10 - Scroll validation log" />
<Text text="Line 11 - Scroll validation log" />
<Text text="Line 12 - Scroll validation log" />
<Text text="Line 13 - Scroll validation log" />
<Text text="Line 14 - Scroll validation log" />
<Text text="Line 15 - Scroll validation log" />
<Text text="Line 16 - Scroll validation log" />
<Text text="Line 17 - Scroll validation log" />
<Text text="Line 18 - Scroll validation log" />
<Text text="Line 19 - Scroll validation log" />
<Text text="Line 20 - Scroll validation log" />
<Text text="Line 21 - Scroll validation log" />
<Text text="Line 22 - Scroll validation log" />
<Text text="Line 23 - Scroll validation log" />
<Text text="Line 24 - Scroll validation log" />
</Column>
</ScrollView>
</Card>
<Card
title="Outside Area"
subtitle="wheel here should not move the log"
height="84">
<Column gap="8">
<Text text="把鼠标移到这个区域再滚动,用来检查 No hovered ScrollView。" />
</Column>
</Card>
</Column>
</View>

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
<View
name="CoreInputShortcutScope"
theme="../../shared/themes/core_validation.xctheme"
shortcut="Ctrl+P"
shortcutCommand="global.command"
shortcutScope="global">
<Column padding="20" gap="12">
<Card
title="Core Validation | Shortcut Scope"
subtitle="验证功能Core shortcut scope 路由与 text input suppression"
tone="accent"
height="100">
<Column gap="6">
<Text text="功能 1验证 Ctrl+P 在 Widget / Panel / Window / Global 间按优先级命中 shortcut。" />
<Text text="功能 2验证 Text Input Proxy 会抑制 Ctrl+P 和 Tab 焦点遍历。" />
</Column>
</Card>
<Button id="global-focus" text="Global Focus" />
<Card
id="window-shell"
title="Window Scope"
subtitle="Ctrl+P -> window.command"
shortcutScopeRoot="window"
shortcut="Ctrl+P"
shortcutCommand="window.command"
shortcutScope="window">
<Column gap="10">
<Text text="先检查优先级widget > panel > window > global。" />
<Button id="window-focus" text="Window Focus" />
<Card
id="panel-shell"
title="Panel Scope"
subtitle="Ctrl+P -> panel.command"
shortcutScopeRoot="panel"
shortcut="Ctrl+P"
shortcutCommand="panel.command"
shortcutScope="panel">
<Column gap="10">
<Button id="panel-focus" text="Panel Focus" />
<Card
id="widget-shell"
title="Widget Scope"
subtitle="Ctrl+P -> widget.command"
tone="accent-alt"
shortcutScopeRoot="widget"
shortcut="Ctrl+P"
shortcutCommand="widget.command"
shortcutScope="widget">
<Column gap="10">
<Button id="widget-focus" text="Widget Focus" />
</Column>
</Card>
<Button id="text-input" text="Text Input Proxy" textInput="true" />
<Text text="操作指引:" />
<Text text="1. 依次点 Widget / Panel / Window / Global Focus再按 Ctrl+P。" />
<Text text="2. 右下角 Recent shortcut 应分别显示 widget / panel / window / global且状态为 handled。" />
<Text text="3. 点 Text Input Proxy 再按 Ctrl+PRecent shortcut 状态应变为 suppressed。" />
<Text text="4. 保持 Text Input Proxy focus 再按 TabResult 应显示 focus traversal suppressedfocus 不应跳走。" />
</Column>
</Card>
</Column>
</Card>
</Column>
</View>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
<View
name="CoreSplitterResizeValidation"
theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能Splitter / pane resize"
subtitle="这一轮只检查分割条拖拽和最小尺寸 clamp"
tone="accent"
height="128">
<Column gap="6">
<Text text="1. 鼠标移到中间 divider右下角 Hover 应落到 workspace-splitter。" />
<Text text="2. 按住左键拖拽:左右 pane 宽度应实时变化Result 应出现 Splitter drag started / Splitter resized。" />
<Text text="3. 向左右极限拖拽:布局应被 primaryMin / secondaryMin clamp 住,不应穿透。" />
<Text text="4. 松开左键Result 应显示 Splitter drag finished。" />
</Column>
</Card>
<Splitter
id="workspace-splitter"
axis="horizontal"
splitRatio="0.38"
splitterSize="10"
splitterHitSize="18"
primaryMin="180"
secondaryMin="220"
height="fill">
<Card id="left-pane" title="Left Empty Pane" subtitle="min 180" height="fill">
<Column gap="8">
<Text text="这里只保留空 pane用来观察 resize。" />
</Column>
</Card>
<Card id="right-pane" title="Right Empty Pane" subtitle="min 220" height="fill">
<Column gap="8">
<Text text="拖拽过程中不应出现翻转、穿透或抖动。" />
</Column>
</Card>
</Splitter>
</Column>
</View>

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<View
name="CoreTabStripSelectionValidation"
theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能TabStrip 选择切换"
subtitle="只验证 tab 头部点击、键盘导航,以及只渲染 selected tab 内容"
tone="accent"
height="156">
<Column gap="6">
<Text text="1. 点击 Scene / Console / Inspector 任一 tab下方内容区应立即切换旧内容不应继续显示。" />
<Text text="2. 先点击一个 tab 让它获得 focus再按 Left / Right / Home / Endselected tab 应变化。" />
<Text text="3. 右下角 Result 正常应显示 Tab selected 或 Tab navigatedFocused 应落在当前 tab。" />
<Text text="4. 这个场景只检查 TabStrip 基础能力,不检查上层业务面板。" />
</Column>
</Card>
<TabStrip
id="core-workspace-tabs"
tabHeaderHeight="34"
tabMinWidth="96"
height="fill">
<Tab id="tab-scene" label="Scene" selected="true">
<Card title="Scene Tab Content" subtitle="selected = Scene" height="fill">
<Column gap="8">
<Text text="这里应该只显示 Scene 的内容占位。" />
</Column>
</Card>
</Tab>
<Tab id="tab-console" label="Console">
<Card title="Console Tab Content" subtitle="selected = Console" height="fill">
<Column gap="8">
<Text text="切换到 Console 后Scene 内容应消失。" />
</Column>
</Card>
</Tab>
<Tab id="tab-inspector" label="Inspector">
<Card title="Inspector Tab Content" subtitle="selected = Inspector" height="fill">
<Column gap="8">
<Text text="按 Home / End 时,也应只保留当前 selected 内容。" />
</Column>
</Card>
</Tab>
</TabStrip>
</Column>
</View>

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
<View
name="CoreWorkspaceComposeValidation"
theme="../../shared/themes/core_validation.xctheme">
<Column width="fill" height="fill" padding="20" gap="12">
<Card
title="功能Workspace compose"
subtitle="只检查共享工作区组合原语的 split + tab + placeholder不检查任何业务面板"
tone="accent"
height="156">
<Column gap="6">
<Text text="1. 先看布局:左、中、右、下四个区域应边界清晰,没有重叠、穿透或错位。" />
<Text text="2. 拖拽 workspace-left-right 和 workspace-top-bottom各区域尺寸应实时变化并被最小尺寸 clamp 住。" />
<Text text="3. 点击中间的 Document A / B / C只应显示当前 selected tab 的 placeholder 内容。" />
<Text text="4. 这个场景只验证工作区组合基础,不代表 Hierarchy / Inspector / Console 已开始实现。" />
</Column>
</Card>
<Splitter
id="workspace-top-bottom"
axis="vertical"
splitRatio="0.76"
splitterSize="10"
splitterHitSize="18"
primaryMin="320"
secondaryMin="120"
height="fill">
<Splitter
id="workspace-left-right"
axis="horizontal"
splitRatio="0.24"
splitterSize="10"
splitterHitSize="18"
primaryMin="160"
secondaryMin="420"
height="fill">
<Card id="workspace-left-slot" title="Navigation Slot" subtitle="placeholder panel host" height="fill">
<Column gap="8">
<Text text="这里是左侧 placeholder slot只检查 pane compose。" />
</Column>
</Card>
<Splitter
id="workspace-center-right"
axis="horizontal"
splitRatio="0.70"
splitterSize="10"
splitterHitSize="18"
primaryMin="260"
secondaryMin="180"
height="fill">
<TabStrip
id="workspace-document-tabs"
tabHeaderHeight="34"
tabMinWidth="112"
height="fill">
<Tab id="tab-document-a" label="Document A" selected="true">
<Card title="Primary Document Slot" subtitle="selected = Document A" height="fill">
<Column gap="8">
<Text text="这里应只显示 Document A 的 placeholder 内容。" />
</Column>
</Card>
</Tab>
<Tab id="tab-document-b" label="Document B">
<Card title="Secondary Document Slot" subtitle="selected = Document B" height="fill">
<Column gap="8">
<Text text="切换到 Document B 后A 的内容应消失。" />
</Column>
</Card>
</Tab>
<Tab id="tab-document-c" label="Document C">
<Card title="Tertiary Document Slot" subtitle="selected = Document C" height="fill">
<Column gap="8">
<Text text="这里只是第三个 placeholder不代表真实面板业务。" />
</Column>
</Card>
</Tab>
</TabStrip>
<Card id="workspace-right-slot" title="Details Slot" subtitle="placeholder panel host" height="fill">
<Column gap="8">
<Text text="这里是右侧 placeholder slot只检查嵌套 split 稳定性。" />
</Column>
</Card>
</Splitter>
</Splitter>
<Card id="workspace-bottom-slot" title="Output Slot" subtitle="placeholder panel host" height="fill">
<Column gap="8">
<Text text="这里是底部 placeholder slot用来检查上下 split compose。" />
</Column>
</Card>
</Splitter>
</Column>
</View>

View File

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

View File

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

View File

@@ -0,0 +1,802 @@
#include "Application.h"
#include <XCEngine/Input/InputTypes.h>
#include <algorithm>
#include <chrono>
#include <filesystem>
#include <sstream>
#include <string>
#include <system_error>
#include <unordered_set>
#include <vector>
#ifndef XCENGINE_CORE_UI_TESTS_REPO_ROOT
#define XCENGINE_CORE_UI_TESTS_REPO_ROOT "."
#endif
namespace XCEngine::Tests::CoreUI {
namespace {
using ::XCEngine::UI::UIColor;
using ::XCEngine::UI::UIDrawData;
using ::XCEngine::UI::UIDrawList;
using ::XCEngine::UI::UIInputEvent;
using ::XCEngine::UI::UIInputEventType;
using ::XCEngine::UI::UIPoint;
using ::XCEngine::UI::UIPointerButton;
using ::XCEngine::UI::UIRect;
using ::XCEngine::UI::Runtime::UIScreenFrameInput;
using ::XCEngine::Input::KeyCode;
constexpr const wchar_t* kWindowClassName = L"XCUICoreValidationHost";
constexpr const wchar_t* kWindowTitle = L"XCUI Core Validation";
constexpr auto kReloadPollInterval = std::chrono::milliseconds(150);
constexpr UIColor kOverlayBgColor(0.10f, 0.10f, 0.10f, 0.95f);
constexpr UIColor kOverlayBorderColor(0.25f, 0.25f, 0.25f, 1.0f);
constexpr UIColor kOverlayTextPrimary(0.93f, 0.93f, 0.93f, 1.0f);
constexpr UIColor kOverlayTextMuted(0.70f, 0.70f, 0.70f, 1.0f);
constexpr UIColor kOverlaySuccess(0.82f, 0.82f, 0.82f, 1.0f);
constexpr UIColor kOverlayFallback(0.56f, 0.56f, 0.56f, 1.0f);
Application* GetApplicationFromWindow(HWND hwnd) {
return reinterpret_cast<Application*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
}
std::filesystem::path GetRepoRootPath() {
std::string root = XCENGINE_CORE_UI_TESTS_REPO_ROOT;
if (root.size() >= 2u && root.front() == '"' && root.back() == '"') {
root = root.substr(1u, root.size() - 2u);
}
return std::filesystem::path(root).lexically_normal();
}
std::string TruncateText(const std::string& text, std::size_t maxLength) {
if (text.size() <= maxLength) {
return text;
}
if (maxLength <= 3u) {
return text.substr(0, maxLength);
}
return text.substr(0, maxLength - 3u) + "...";
}
std::string ExtractStateKeyTail(const std::string& stateKey) {
if (stateKey.empty()) {
return "-";
}
const std::size_t separator = stateKey.find_last_of('/');
if (separator == std::string::npos || separator + 1u >= stateKey.size()) {
return stateKey;
}
return stateKey.substr(separator + 1u);
}
std::string FormatFloat(float value) {
std::ostringstream stream;
stream.setf(std::ios::fixed, std::ios::floatfield);
stream.precision(1);
stream << value;
return stream.str();
}
std::string FormatPoint(const UIPoint& point) {
return "(" + FormatFloat(point.x) + ", " + FormatFloat(point.y) + ")";
}
std::string FormatRect(const UIRect& rect) {
return "(" + FormatFloat(rect.x) +
", " + FormatFloat(rect.y) +
", " + FormatFloat(rect.width) +
", " + FormatFloat(rect.height) +
")";
}
std::int32_t MapVirtualKeyToUIKeyCode(WPARAM wParam) {
switch (wParam) {
case 'A': return static_cast<std::int32_t>(KeyCode::A);
case 'B': return static_cast<std::int32_t>(KeyCode::B);
case 'C': return static_cast<std::int32_t>(KeyCode::C);
case 'D': return static_cast<std::int32_t>(KeyCode::D);
case 'E': return static_cast<std::int32_t>(KeyCode::E);
case 'F': return static_cast<std::int32_t>(KeyCode::F);
case 'G': return static_cast<std::int32_t>(KeyCode::G);
case 'H': return static_cast<std::int32_t>(KeyCode::H);
case 'I': return static_cast<std::int32_t>(KeyCode::I);
case 'J': return static_cast<std::int32_t>(KeyCode::J);
case 'K': return static_cast<std::int32_t>(KeyCode::K);
case 'L': return static_cast<std::int32_t>(KeyCode::L);
case 'M': return static_cast<std::int32_t>(KeyCode::M);
case 'N': return static_cast<std::int32_t>(KeyCode::N);
case 'O': return static_cast<std::int32_t>(KeyCode::O);
case 'P': return static_cast<std::int32_t>(KeyCode::P);
case 'Q': return static_cast<std::int32_t>(KeyCode::Q);
case 'R': return static_cast<std::int32_t>(KeyCode::R);
case 'S': return static_cast<std::int32_t>(KeyCode::S);
case 'T': return static_cast<std::int32_t>(KeyCode::T);
case 'U': return static_cast<std::int32_t>(KeyCode::U);
case 'V': return static_cast<std::int32_t>(KeyCode::V);
case 'W': return static_cast<std::int32_t>(KeyCode::W);
case 'X': return static_cast<std::int32_t>(KeyCode::X);
case 'Y': return static_cast<std::int32_t>(KeyCode::Y);
case 'Z': return static_cast<std::int32_t>(KeyCode::Z);
case '0': return static_cast<std::int32_t>(KeyCode::Zero);
case '1': return static_cast<std::int32_t>(KeyCode::One);
case '2': return static_cast<std::int32_t>(KeyCode::Two);
case '3': return static_cast<std::int32_t>(KeyCode::Three);
case '4': return static_cast<std::int32_t>(KeyCode::Four);
case '5': return static_cast<std::int32_t>(KeyCode::Five);
case '6': return static_cast<std::int32_t>(KeyCode::Six);
case '7': return static_cast<std::int32_t>(KeyCode::Seven);
case '8': return static_cast<std::int32_t>(KeyCode::Eight);
case '9': return static_cast<std::int32_t>(KeyCode::Nine);
case VK_SPACE: return static_cast<std::int32_t>(KeyCode::Space);
case VK_TAB: return static_cast<std::int32_t>(KeyCode::Tab);
case VK_RETURN: return static_cast<std::int32_t>(KeyCode::Enter);
case VK_ESCAPE: return static_cast<std::int32_t>(KeyCode::Escape);
case VK_SHIFT: return static_cast<std::int32_t>(KeyCode::LeftShift);
case VK_CONTROL: return static_cast<std::int32_t>(KeyCode::LeftCtrl);
case VK_MENU: return static_cast<std::int32_t>(KeyCode::LeftAlt);
case VK_UP: return static_cast<std::int32_t>(KeyCode::Up);
case VK_DOWN: return static_cast<std::int32_t>(KeyCode::Down);
case VK_LEFT: return static_cast<std::int32_t>(KeyCode::Left);
case VK_RIGHT: return static_cast<std::int32_t>(KeyCode::Right);
case VK_HOME: return static_cast<std::int32_t>(KeyCode::Home);
case VK_END: return static_cast<std::int32_t>(KeyCode::End);
case VK_PRIOR: return static_cast<std::int32_t>(KeyCode::PageUp);
case VK_NEXT: return static_cast<std::int32_t>(KeyCode::PageDown);
case VK_DELETE: return static_cast<std::int32_t>(KeyCode::Delete);
case VK_BACK: return static_cast<std::int32_t>(KeyCode::Backspace);
case VK_F1: return static_cast<std::int32_t>(KeyCode::F1);
case VK_F2: return static_cast<std::int32_t>(KeyCode::F2);
case VK_F3: return static_cast<std::int32_t>(KeyCode::F3);
case VK_F4: return static_cast<std::int32_t>(KeyCode::F4);
case VK_F5: return static_cast<std::int32_t>(KeyCode::F5);
case VK_F6: return static_cast<std::int32_t>(KeyCode::F6);
case VK_F7: return static_cast<std::int32_t>(KeyCode::F7);
case VK_F8: return static_cast<std::int32_t>(KeyCode::F8);
case VK_F9: return static_cast<std::int32_t>(KeyCode::F9);
case VK_F10: return static_cast<std::int32_t>(KeyCode::F10);
case VK_F11: return static_cast<std::int32_t>(KeyCode::F11);
case VK_F12: return static_cast<std::int32_t>(KeyCode::F12);
default: return static_cast<std::int32_t>(KeyCode::None);
}
}
bool IsRepeatKeyMessage(LPARAM lParam) {
return (static_cast<unsigned long>(lParam) & (1ul << 30)) != 0ul;
}
} // namespace
Application::Application(std::string requestedScenarioId)
: m_screenPlayer(m_documentHost)
, m_requestedScenarioId(std::move(requestedScenarioId)) {
}
int Application::Run(HINSTANCE hInstance, int nCmdShow) {
if (!Initialize(hInstance, nCmdShow)) {
Shutdown();
return 1;
}
MSG message = {};
while (message.message != WM_QUIT) {
if (PeekMessageW(&message, nullptr, 0U, 0U, PM_REMOVE)) {
TranslateMessage(&message);
DispatchMessageW(&message);
continue;
}
RenderFrame();
Sleep(8);
}
Shutdown();
return static_cast<int>(message.wParam);
}
bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) {
m_hInstance = hInstance;
WNDCLASSEXW windowClass = {};
windowClass.cbSize = sizeof(windowClass);
windowClass.style = CS_HREDRAW | CS_VREDRAW;
windowClass.lpfnWndProc = &Application::WndProc;
windowClass.hInstance = hInstance;
windowClass.hCursor = LoadCursorW(nullptr, IDC_ARROW);
windowClass.lpszClassName = kWindowClassName;
m_windowClassAtom = RegisterClassExW(&windowClass);
if (m_windowClassAtom == 0) {
return false;
}
m_hwnd = CreateWindowExW(
0,
kWindowClassName,
kWindowTitle,
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
CW_USEDEFAULT,
CW_USEDEFAULT,
1440,
900,
nullptr,
nullptr,
hInstance,
this);
if (m_hwnd == nullptr) {
return false;
}
ShowWindow(m_hwnd, nCmdShow);
UpdateWindow(m_hwnd);
if (!m_renderer.Initialize(m_hwnd)) {
return false;
}
m_startTime = std::chrono::steady_clock::now();
m_lastFrameTime = m_startTime;
const CoreValidationScenario* initialScenario = m_requestedScenarioId.empty()
? &GetDefaultCoreValidationScenario()
: FindCoreValidationScenario(m_requestedScenarioId);
if (initialScenario == nullptr) {
initialScenario = &GetDefaultCoreValidationScenario();
}
m_autoScreenshot.Initialize(initialScenario->captureRootPath);
LoadStructuredScreen("startup");
return true;
}
void Application::Shutdown() {
m_autoScreenshot.Shutdown();
m_screenPlayer.Unload();
m_trackedFiles.clear();
m_screenAsset = {};
m_useStructuredScreen = false;
m_runtimeStatus.clear();
m_runtimeError.clear();
m_frameIndex = 0;
m_renderer.Shutdown();
if (m_hwnd != nullptr && IsWindow(m_hwnd)) {
DestroyWindow(m_hwnd);
}
m_hwnd = nullptr;
if (m_windowClassAtom != 0 && m_hInstance != nullptr) {
UnregisterClassW(kWindowClassName, m_hInstance);
m_windowClassAtom = 0;
}
}
void Application::RenderFrame() {
if (m_hwnd == nullptr) {
return;
}
RECT clientRect = {};
GetClientRect(m_hwnd, &clientRect);
const float width = static_cast<float>((std::max)(clientRect.right - clientRect.left, 1L));
const float height = static_cast<float>((std::max)(clientRect.bottom - clientRect.top, 1L));
const auto now = std::chrono::steady_clock::now();
double deltaTimeSeconds = std::chrono::duration<double>(now - m_lastFrameTime).count();
if (deltaTimeSeconds <= 0.0) {
deltaTimeSeconds = 1.0 / 60.0;
}
m_lastFrameTime = now;
RefreshStructuredScreen();
std::vector<UIInputEvent> frameEvents = std::move(m_pendingInputEvents);
m_pendingInputEvents.clear();
UIDrawData drawData = {};
if (m_useStructuredScreen && m_screenPlayer.IsLoaded()) {
UIScreenFrameInput input = {};
input.viewportRect = UIRect(0.0f, 0.0f, width, height);
input.events = std::move(frameEvents);
input.deltaTimeSeconds = deltaTimeSeconds;
input.frameIndex = ++m_frameIndex;
input.focused = GetForegroundWindow() == m_hwnd;
const auto& frame = m_screenPlayer.Update(input);
for (const auto& drawList : frame.drawData.GetDrawLists()) {
drawData.AddDrawList(drawList);
}
m_runtimeStatus = m_activeScenario != nullptr
? m_activeScenario->displayName
: "Core UI Validation";
m_runtimeError = frame.errorMessage;
}
if (drawData.Empty()) {
m_runtimeStatus = "Core UI Validation | Load Error";
if (m_runtimeError.empty() && !m_screenPlayer.IsLoaded()) {
m_runtimeError = m_screenPlayer.GetLastError();
}
}
AppendRuntimeOverlay(drawData, width, height);
const bool framePresented = m_renderer.Render(drawData);
m_autoScreenshot.CaptureIfRequested(
m_renderer,
drawData,
static_cast<unsigned int>(width),
static_cast<unsigned int>(height),
framePresented);
}
void Application::OnResize(UINT width, UINT height) {
if (width == 0 || height == 0) {
return;
}
m_renderer.Resize(width, height);
}
void Application::QueuePointerEvent(UIInputEventType type, UIPointerButton button, WPARAM wParam, LPARAM lParam) {
UIInputEvent event = {};
event.type = type;
event.pointerButton = button;
event.position = UIPoint(
static_cast<float>(GET_X_LPARAM(lParam)),
static_cast<float>(GET_Y_LPARAM(lParam)));
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<size_t>(wParam));
m_pendingInputEvents.push_back(event);
}
void Application::QueuePointerLeaveEvent() {
UIInputEvent event = {};
event.type = UIInputEventType::PointerLeave;
if (m_hwnd != nullptr) {
POINT clientPoint = {};
GetCursorPos(&clientPoint);
ScreenToClient(m_hwnd, &clientPoint);
event.position = UIPoint(static_cast<float>(clientPoint.x), static_cast<float>(clientPoint.y));
}
m_pendingInputEvents.push_back(event);
}
void Application::QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam) {
if (m_hwnd == nullptr) {
return;
}
POINT screenPoint = {
GET_X_LPARAM(lParam),
GET_Y_LPARAM(lParam)
};
ScreenToClient(m_hwnd, &screenPoint);
UIInputEvent event = {};
event.type = UIInputEventType::PointerWheel;
event.position = UIPoint(static_cast<float>(screenPoint.x), static_cast<float>(screenPoint.y));
event.wheelDelta = static_cast<float>(wheelDelta);
event.modifiers = m_inputModifierTracker.BuildPointerModifiers(static_cast<size_t>(wParam));
m_pendingInputEvents.push_back(event);
}
void Application::QueueKeyEvent(UIInputEventType type, WPARAM wParam, LPARAM lParam) {
UIInputEvent event = {};
event.type = type;
event.keyCode = MapVirtualKeyToUIKeyCode(wParam);
event.modifiers = m_inputModifierTracker.ApplyKeyMessage(type, wParam, lParam);
event.repeat = IsRepeatKeyMessage(lParam);
m_pendingInputEvents.push_back(event);
}
void Application::QueueCharacterEvent(WPARAM wParam, LPARAM) {
UIInputEvent event = {};
event.type = UIInputEventType::Character;
event.character = static_cast<std::uint32_t>(wParam);
event.modifiers = m_inputModifierTracker.GetCurrentModifiers();
m_pendingInputEvents.push_back(event);
}
void Application::QueueWindowFocusEvent(UIInputEventType type) {
UIInputEvent event = {};
event.type = type;
m_pendingInputEvents.push_back(event);
}
bool Application::LoadStructuredScreen(const char* triggerReason) {
(void)triggerReason;
std::string scenarioLoadWarning = {};
const CoreValidationScenario* scenario = m_requestedScenarioId.empty()
? &GetDefaultCoreValidationScenario()
: FindCoreValidationScenario(m_requestedScenarioId);
if (scenario == nullptr) {
scenario = &GetDefaultCoreValidationScenario();
scenarioLoadWarning = "Unknown validation scenario: " + m_requestedScenarioId;
}
m_activeScenario = scenario;
m_screenAsset = {};
m_screenAsset.screenId = scenario->id;
m_screenAsset.documentPath = scenario->documentPath.string();
m_screenAsset.themePath = scenario->themePath.string();
const bool loaded = m_screenPlayer.Load(m_screenAsset);
m_useStructuredScreen = loaded;
m_runtimeStatus = loaded ? scenario->displayName : "Core UI Validation | Load Error";
m_runtimeError = loaded
? scenarioLoadWarning
: (scenarioLoadWarning.empty()
? m_screenPlayer.GetLastError()
: scenarioLoadWarning + " | " + m_screenPlayer.GetLastError());
RebuildTrackedFileStates();
return loaded;
}
void Application::RefreshStructuredScreen() {
const auto now = std::chrono::steady_clock::now();
if (m_lastReloadPollTime.time_since_epoch().count() != 0 &&
now - m_lastReloadPollTime < kReloadPollInterval) {
return;
}
m_lastReloadPollTime = now;
if (DetectTrackedFileChange()) {
LoadStructuredScreen("reload");
}
}
void Application::RebuildTrackedFileStates() {
namespace fs = std::filesystem;
m_trackedFiles.clear();
std::unordered_set<std::string> seenPaths = {};
std::error_code errorCode = {};
auto appendTrackedPath = [&](const std::string& rawPath) {
if (rawPath.empty()) {
return;
}
const fs::path normalizedPath = fs::path(rawPath).lexically_normal();
const std::string key = normalizedPath.string();
if (!seenPaths.insert(key).second) {
return;
}
TrackedFileState state = {};
state.path = normalizedPath;
state.exists = fs::exists(normalizedPath, errorCode);
errorCode.clear();
if (state.exists) {
state.writeTime = fs::last_write_time(normalizedPath, errorCode);
errorCode.clear();
}
m_trackedFiles.push_back(std::move(state));
};
appendTrackedPath(m_screenAsset.documentPath);
appendTrackedPath(m_screenAsset.themePath);
if (const auto* document = m_screenPlayer.GetDocument(); document != nullptr) {
for (const std::string& dependency : document->dependencies) {
appendTrackedPath(dependency);
}
}
}
bool Application::DetectTrackedFileChange() const {
namespace fs = std::filesystem;
std::error_code errorCode = {};
for (const TrackedFileState& trackedFile : m_trackedFiles) {
const bool existsNow = fs::exists(trackedFile.path, errorCode);
errorCode.clear();
if (existsNow != trackedFile.exists) {
return true;
}
if (!existsNow) {
continue;
}
const auto writeTimeNow = fs::last_write_time(trackedFile.path, errorCode);
errorCode.clear();
if (writeTimeNow != trackedFile.writeTime) {
return true;
}
}
return false;
}
void Application::AppendRuntimeOverlay(UIDrawData& drawData, float width, float height) const {
const bool authoredMode = m_useStructuredScreen && m_screenPlayer.IsLoaded();
const float panelWidth = authoredMode ? 460.0f : 360.0f;
std::vector<std::string> detailLines = {};
detailLines.push_back(
authoredMode
? "Hot reload watches authored UI resources."
: "Authored validation scene failed to load.");
if (m_activeScenario != nullptr) {
detailLines.push_back("Scenario: " + m_activeScenario->id);
}
if (authoredMode) {
const auto& inputDebug = m_documentHost.GetInputDebugSnapshot();
const auto& scrollDebug = m_documentHost.GetScrollDebugSnapshot();
detailLines.push_back(
"Hover | Focus: " +
ExtractStateKeyTail(inputDebug.hoveredStateKey) +
" | " +
ExtractStateKeyTail(inputDebug.focusedStateKey));
detailLines.push_back(
"Active | Capture: " +
ExtractStateKeyTail(inputDebug.activeStateKey) +
" | " +
ExtractStateKeyTail(inputDebug.captureStateKey));
detailLines.push_back(
"Scope W/P/Wg: " +
ExtractStateKeyTail(inputDebug.windowScopeStateKey) +
" | " +
ExtractStateKeyTail(inputDebug.panelScopeStateKey) +
" | " +
ExtractStateKeyTail(inputDebug.widgetScopeStateKey));
detailLines.push_back(
std::string("Text input: ") +
(inputDebug.textInputActive ? "active" : "idle"));
if (!inputDebug.recentShortcutCommandId.empty()) {
detailLines.push_back(
"Recent shortcut: " +
inputDebug.recentShortcutScope +
" -> " +
inputDebug.recentShortcutCommandId);
detailLines.push_back(
std::string("Recent shortcut state: ") +
(inputDebug.recentShortcutHandled
? "handled"
: (inputDebug.recentShortcutSuppressed ? "suppressed" : "observed")) +
" @ " +
ExtractStateKeyTail(inputDebug.recentShortcutOwnerStateKey));
} else {
detailLines.push_back("Recent shortcut: none");
}
if (!inputDebug.lastEventType.empty()) {
const std::string eventPosition = inputDebug.lastEventType == "KeyDown" ||
inputDebug.lastEventType == "KeyUp" ||
inputDebug.lastEventType == "Character" ||
inputDebug.lastEventType == "FocusGained" ||
inputDebug.lastEventType == "FocusLost"
? std::string()
: " at " + FormatPoint(inputDebug.pointerPosition);
detailLines.push_back(
"Last input: " +
inputDebug.lastEventType +
eventPosition);
detailLines.push_back(
"Route: " +
inputDebug.lastTargetKind +
" -> " +
ExtractStateKeyTail(inputDebug.lastTargetStateKey));
if (!inputDebug.lastShortcutCommandId.empty()) {
detailLines.push_back(
"Shortcut: " +
inputDebug.lastShortcutScope +
" -> " +
inputDebug.lastShortcutCommandId);
detailLines.push_back(
std::string("Shortcut state: ") +
(inputDebug.lastShortcutHandled
? "handled"
: (inputDebug.lastShortcutSuppressed ? "suppressed" : "observed")) +
" @ " +
ExtractStateKeyTail(inputDebug.lastShortcutOwnerStateKey));
}
detailLines.push_back(
"Last event result: " +
(inputDebug.lastResult.empty() ? std::string("n/a") : inputDebug.lastResult));
}
detailLines.push_back(
"Scroll target | Primary: " +
ExtractStateKeyTail(scrollDebug.lastTargetStateKey) +
" | " +
ExtractStateKeyTail(scrollDebug.primaryTargetStateKey));
detailLines.push_back(
"Scroll offset B/A: " +
FormatFloat(scrollDebug.lastOffsetBefore) +
" -> " +
FormatFloat(scrollDebug.lastOffsetAfter) +
" | overflow " +
FormatFloat(scrollDebug.lastOverflow));
detailLines.push_back(
"Scroll H/T: " +
std::to_string(scrollDebug.handledWheelEventCount) +
"/" +
std::to_string(scrollDebug.totalWheelEventCount) +
" | " +
(scrollDebug.lastResult.empty() ? std::string("n/a") : scrollDebug.lastResult));
}
if (m_autoScreenshot.HasPendingCapture()) {
detailLines.push_back("Shot pending...");
} else if (!m_autoScreenshot.GetLastCaptureSummary().empty()) {
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureSummary(), 78u));
} else {
detailLines.push_back("Screenshots: F12 -> current scenario captures/");
}
if (!m_runtimeError.empty()) {
detailLines.push_back(TruncateText(m_runtimeError, 78u));
} else if (!m_autoScreenshot.GetLastCaptureError().empty()) {
detailLines.push_back(TruncateText(m_autoScreenshot.GetLastCaptureError(), 78u));
} else if (!authoredMode) {
detailLines.push_back("No fallback sandbox is rendered in this host.");
}
const float panelHeight = 38.0f + static_cast<float>(detailLines.size()) * 18.0f;
const UIRect panelRect(width - panelWidth - 16.0f, height - panelHeight - 42.0f, panelWidth, panelHeight);
UIDrawList& overlay = drawData.EmplaceDrawList("Core UI Validation Overlay");
overlay.AddFilledRect(panelRect, kOverlayBgColor, 10.0f);
overlay.AddRectOutline(panelRect, kOverlayBorderColor, 1.0f, 10.0f);
overlay.AddFilledRect(
UIRect(panelRect.x + 12.0f, panelRect.y + 14.0f, 8.0f, 8.0f),
authoredMode ? kOverlaySuccess : kOverlayFallback,
4.0f);
overlay.AddText(
UIPoint(panelRect.x + 28.0f, panelRect.y + 10.0f),
m_runtimeStatus.empty() ? "Core UI Validation" : m_runtimeStatus,
kOverlayTextPrimary,
14.0f);
float detailY = panelRect.y + 30.0f;
for (std::size_t index = 0; index < detailLines.size(); ++index) {
const bool lastLine = index + 1u == detailLines.size();
overlay.AddText(
UIPoint(panelRect.x + 28.0f, detailY),
detailLines[index],
lastLine && (!m_runtimeError.empty() || !m_autoScreenshot.GetLastCaptureError().empty())
? kOverlayFallback
: kOverlayTextMuted,
12.0f);
detailY += 18.0f;
}
}
std::filesystem::path Application::ResolveRepoRelativePath(const char* relativePath) {
return (GetRepoRootPath() / relativePath).lexically_normal();
}
LRESULT CALLBACK Application::WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
if (message == WM_NCCREATE) {
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
auto* application = reinterpret_cast<Application*>(createStruct->lpCreateParams);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(application));
return TRUE;
}
Application* application = GetApplicationFromWindow(hwnd);
switch (message) {
case WM_SIZE:
if (application != nullptr && wParam != SIZE_MINIMIZED) {
application->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
}
return 0;
case WM_PAINT:
if (application != nullptr) {
PAINTSTRUCT paintStruct = {};
BeginPaint(hwnd, &paintStruct);
application->RenderFrame();
EndPaint(hwnd, &paintStruct);
return 0;
}
break;
case WM_MOUSEMOVE:
if (application != nullptr) {
if (!application->m_trackingMouseLeave) {
TRACKMOUSEEVENT trackMouseEvent = {};
trackMouseEvent.cbSize = sizeof(trackMouseEvent);
trackMouseEvent.dwFlags = TME_LEAVE;
trackMouseEvent.hwndTrack = hwnd;
if (TrackMouseEvent(&trackMouseEvent)) {
application->m_trackingMouseLeave = true;
}
}
application->QueuePointerEvent(UIInputEventType::PointerMove, UIPointerButton::None, wParam, lParam);
return 0;
}
break;
case WM_MOUSELEAVE:
if (application != nullptr) {
application->m_trackingMouseLeave = false;
application->QueuePointerLeaveEvent();
return 0;
}
break;
case WM_LBUTTONDOWN:
if (application != nullptr) {
SetFocus(hwnd);
SetCapture(hwnd);
application->QueuePointerEvent(UIInputEventType::PointerButtonDown, UIPointerButton::Left, wParam, lParam);
return 0;
}
break;
case WM_LBUTTONUP:
if (application != nullptr) {
if (GetCapture() == hwnd) {
ReleaseCapture();
}
application->QueuePointerEvent(UIInputEventType::PointerButtonUp, UIPointerButton::Left, wParam, lParam);
return 0;
}
break;
case WM_MOUSEWHEEL:
if (application != nullptr) {
application->QueuePointerWheelEvent(GET_WHEEL_DELTA_WPARAM(wParam), wParam, lParam);
return 0;
}
break;
case WM_SETFOCUS:
if (application != nullptr) {
application->m_inputModifierTracker.SyncFromSystemState();
application->QueueWindowFocusEvent(UIInputEventType::FocusGained);
return 0;
}
break;
case WM_KILLFOCUS:
if (application != nullptr) {
application->m_inputModifierTracker.Reset();
application->QueueWindowFocusEvent(UIInputEventType::FocusLost);
return 0;
}
break;
case WM_KEYDOWN:
case WM_SYSKEYDOWN:
if (application != nullptr) {
if (wParam == VK_F12) {
application->m_autoScreenshot.RequestCapture("manual_f12");
}
application->QueueKeyEvent(UIInputEventType::KeyDown, wParam, lParam);
return 0;
}
break;
case WM_KEYUP:
case WM_SYSKEYUP:
if (application != nullptr) {
application->QueueKeyEvent(UIInputEventType::KeyUp, wParam, lParam);
return 0;
}
break;
case WM_CHAR:
if (application != nullptr) {
application->QueueCharacterEvent(wParam, lParam);
return 0;
}
break;
case WM_ERASEBKGND:
return 1;
case WM_DESTROY:
if (application != nullptr) {
application->m_hwnd = nullptr;
}
PostQuitMessage(0);
return 0;
default:
break;
}
return DefWindowProcW(hwnd, message, wParam, lParam);
}
int RunCoreUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId) {
Application application(std::move(requestedScenarioId));
return application.Run(hInstance, nCmdShow);
}
} // namespace XCEngine::Tests::CoreUI

View File

@@ -0,0 +1,83 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include "AutoScreenshot.h"
#include "CoreValidationScenario.h"
#include "InputModifierTracker.h"
#include "NativeRenderer.h"
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <windows.h>
#include <windowsx.h>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace XCEngine::Tests::CoreUI {
class Application {
public:
explicit Application(std::string requestedScenarioId = {});
int Run(HINSTANCE hInstance, int nCmdShow);
private:
struct TrackedFileState {
std::filesystem::path path = {};
std::filesystem::file_time_type writeTime = {};
bool exists = false;
};
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam);
bool Initialize(HINSTANCE hInstance, int nCmdShow);
void Shutdown();
void RenderFrame();
void OnResize(UINT width, UINT height);
void QueuePointerEvent(::XCEngine::UI::UIInputEventType type, ::XCEngine::UI::UIPointerButton button, WPARAM wParam, LPARAM lParam);
void QueuePointerLeaveEvent();
void QueuePointerWheelEvent(short wheelDelta, WPARAM wParam, LPARAM lParam);
void QueueKeyEvent(::XCEngine::UI::UIInputEventType type, WPARAM wParam, LPARAM lParam);
void QueueCharacterEvent(WPARAM wParam, LPARAM lParam);
void QueueWindowFocusEvent(::XCEngine::UI::UIInputEventType type);
bool LoadStructuredScreen(const char* triggerReason);
void RefreshStructuredScreen();
void RebuildTrackedFileStates();
bool DetectTrackedFileChange() const;
void AppendRuntimeOverlay(::XCEngine::UI::UIDrawData& drawData, float width, float height) const;
static std::filesystem::path ResolveRepoRelativePath(const char* relativePath);
HWND m_hwnd = nullptr;
HINSTANCE m_hInstance = nullptr;
ATOM m_windowClassAtom = 0;
Host::NativeRenderer m_renderer;
Host::AutoScreenshotController m_autoScreenshot;
::XCEngine::UI::Runtime::UIDocumentScreenHost m_documentHost;
::XCEngine::UI::Runtime::UIScreenPlayer m_screenPlayer;
::XCEngine::UI::Runtime::UIScreenAsset m_screenAsset = {};
const CoreValidationScenario* m_activeScenario = nullptr;
std::string m_requestedScenarioId = {};
std::vector<TrackedFileState> m_trackedFiles = {};
std::chrono::steady_clock::time_point m_startTime = {};
std::chrono::steady_clock::time_point m_lastFrameTime = {};
std::chrono::steady_clock::time_point m_lastReloadPollTime = {};
std::uint64_t m_frameIndex = 0;
std::vector<::XCEngine::UI::UIInputEvent> m_pendingInputEvents = {};
Host::InputModifierTracker m_inputModifierTracker = {};
bool m_trackingMouseLeave = false;
bool m_useStructuredScreen = false;
std::string m_runtimeStatus = {};
std::string m_runtimeError = {};
};
int RunCoreUIValidationApp(HINSTANCE hInstance, int nCmdShow, std::string requestedScenarioId = {});
} // namespace XCEngine::Tests::CoreUI

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,173 @@
#pragma once
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <XCEngine/UI/Types.h>
#include <windows.h>
#include <cstddef>
#include <cstdint>
namespace XCEngine::Tests::CoreUI::Host {
class InputModifierTracker {
public:
void Reset() {
m_leftShift = false;
m_rightShift = false;
m_leftControl = false;
m_rightControl = false;
m_leftAlt = false;
m_rightAlt = false;
m_leftSuper = false;
m_rightSuper = false;
}
void SyncFromSystemState() {
m_leftShift = (GetKeyState(VK_LSHIFT) & 0x8000) != 0;
m_rightShift = (GetKeyState(VK_RSHIFT) & 0x8000) != 0;
m_leftControl = (GetKeyState(VK_LCONTROL) & 0x8000) != 0;
m_rightControl = (GetKeyState(VK_RCONTROL) & 0x8000) != 0;
m_leftAlt = (GetKeyState(VK_LMENU) & 0x8000) != 0;
m_rightAlt = (GetKeyState(VK_RMENU) & 0x8000) != 0;
m_leftSuper = (GetKeyState(VK_LWIN) & 0x8000) != 0;
m_rightSuper = (GetKeyState(VK_RWIN) & 0x8000) != 0;
}
::XCEngine::UI::UIInputModifiers GetCurrentModifiers() const {
return BuildModifiers();
}
::XCEngine::UI::UIInputModifiers BuildPointerModifiers(std::size_t wParam) const {
::XCEngine::UI::UIInputModifiers modifiers = BuildModifiers();
modifiers.shift = modifiers.shift || (wParam & MK_SHIFT) != 0;
modifiers.control = modifiers.control || (wParam & MK_CONTROL) != 0;
return modifiers;
}
::XCEngine::UI::UIInputModifiers ApplyKeyMessage(
::XCEngine::UI::UIInputEventType type,
WPARAM wParam,
LPARAM lParam) {
if (type == ::XCEngine::UI::UIInputEventType::KeyDown) {
SetModifierState(ResolveModifierKey(wParam, lParam), true);
} else if (type == ::XCEngine::UI::UIInputEventType::KeyUp) {
SetModifierState(ResolveModifierKey(wParam, lParam), false);
}
return BuildModifiers();
}
private:
enum class ModifierKey : std::uint8_t {
None = 0,
LeftShift,
RightShift,
LeftControl,
RightControl,
LeftAlt,
RightAlt,
LeftSuper,
RightSuper
};
static bool IsExtendedKey(LPARAM lParam) {
return (static_cast<std::uint32_t>(lParam) & 0x01000000u) != 0u;
}
static std::uint32_t ExtractScanCode(LPARAM lParam) {
return (static_cast<std::uint32_t>(lParam) >> 16u) & 0xffu;
}
static ModifierKey ResolveModifierKey(WPARAM wParam, LPARAM lParam) {
switch (static_cast<std::uint32_t>(wParam)) {
case VK_SHIFT: {
const UINT shiftVirtualKey = MapVirtualKeyW(ExtractScanCode(lParam), MAPVK_VSC_TO_VK_EX);
return shiftVirtualKey == VK_RSHIFT
? ModifierKey::RightShift
: ModifierKey::LeftShift;
}
case VK_LSHIFT:
return ModifierKey::LeftShift;
case VK_RSHIFT:
return ModifierKey::RightShift;
case VK_CONTROL:
return IsExtendedKey(lParam)
? ModifierKey::RightControl
: ModifierKey::LeftControl;
case VK_LCONTROL:
return ModifierKey::LeftControl;
case VK_RCONTROL:
return ModifierKey::RightControl;
case VK_MENU:
return IsExtendedKey(lParam)
? ModifierKey::RightAlt
: ModifierKey::LeftAlt;
case VK_LMENU:
return ModifierKey::LeftAlt;
case VK_RMENU:
return ModifierKey::RightAlt;
case VK_LWIN:
return ModifierKey::LeftSuper;
case VK_RWIN:
return ModifierKey::RightSuper;
default:
return ModifierKey::None;
}
}
void SetModifierState(ModifierKey key, bool pressed) {
switch (key) {
case ModifierKey::LeftShift:
m_leftShift = pressed;
break;
case ModifierKey::RightShift:
m_rightShift = pressed;
break;
case ModifierKey::LeftControl:
m_leftControl = pressed;
break;
case ModifierKey::RightControl:
m_rightControl = pressed;
break;
case ModifierKey::LeftAlt:
m_leftAlt = pressed;
break;
case ModifierKey::RightAlt:
m_rightAlt = pressed;
break;
case ModifierKey::LeftSuper:
m_leftSuper = pressed;
break;
case ModifierKey::RightSuper:
m_rightSuper = pressed;
break;
case ModifierKey::None:
default:
break;
}
}
::XCEngine::UI::UIInputModifiers BuildModifiers() const {
::XCEngine::UI::UIInputModifiers modifiers = {};
modifiers.shift = m_leftShift || m_rightShift;
modifiers.control = m_leftControl || m_rightControl;
modifiers.alt = m_leftAlt || m_rightAlt;
modifiers.super = m_leftSuper || m_rightSuper;
return modifiers;
}
bool m_leftShift = false;
bool m_rightShift = false;
bool m_leftControl = false;
bool m_rightControl = false;
bool m_leftAlt = false;
bool m_rightAlt = false;
bool m_leftSuper = false;
bool m_rightSuper = false;
};
} // namespace XCEngine::Tests::CoreUI::Host

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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