859 lines
31 KiB
C++
859 lines
31 KiB
C++
#ifndef NOMINMAX
|
||
#define NOMINMAX
|
||
#endif
|
||
|
||
#include <XCEditor/Shell/UIEditorWorkspaceController.h>
|
||
#include <XCEditor/Shell/UIEditorWorkspaceLayoutPersistence.h>
|
||
#include "Host/AutoScreenshot.h"
|
||
#include "Host/NativeRenderer.h"
|
||
|
||
#include <XCEngine/UI/DrawData.h>
|
||
|
||
#include <windows.h>
|
||
#include <windowsx.h>
|
||
|
||
#include <algorithm>
|
||
#include <filesystem>
|
||
#include <iomanip>
|
||
#include <sstream>
|
||
#include <string>
|
||
#include <string_view>
|
||
#include <utility>
|
||
#include <vector>
|
||
|
||
#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT
|
||
#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "."
|
||
#endif
|
||
|
||
namespace {
|
||
|
||
using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController;
|
||
using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceSession;
|
||
using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel;
|
||
using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit;
|
||
using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack;
|
||
using XCEngine::UI::Editor::CollectUIEditorWorkspaceVisiblePanels;
|
||
using XCEngine::UI::Editor::FindUIEditorPanelSessionState;
|
||
using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandStatusName;
|
||
using XCEngine::UI::Editor::GetUIEditorWorkspaceLayoutOperationStatusName;
|
||
using XCEngine::UI::Editor::SerializeUIEditorWorkspaceLayoutSnapshot;
|
||
using XCEngine::UI::Editor::UIEditorPanelRegistry;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceCommand;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceCommandResult;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceCommandStatus;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceController;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceLayoutOperationResult;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceLayoutOperationStatus;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceLayoutSnapshot;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceModel;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceSession;
|
||
using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis;
|
||
using XCEngine::UI::UIColor;
|
||
using XCEngine::UI::UIDrawData;
|
||
using XCEngine::UI::UIDrawList;
|
||
using XCEngine::UI::UIPoint;
|
||
using XCEngine::UI::UIRect;
|
||
using XCEngine::UI::Editor::Host::AutoScreenshotController;
|
||
using XCEngine::UI::Editor::Host::NativeRenderer;
|
||
|
||
constexpr const wchar_t* kWindowClassName = L"XCUIEditorLayoutPersistenceValidation";
|
||
constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Layout Persistence";
|
||
|
||
constexpr UIColor kWindowBg(0.14f, 0.14f, 0.14f, 1.0f);
|
||
constexpr UIColor kCardBg(0.19f, 0.19f, 0.19f, 1.0f);
|
||
constexpr UIColor kCardBorder(0.29f, 0.29f, 0.29f, 1.0f);
|
||
constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f);
|
||
constexpr UIColor kTextMuted(0.70f, 0.70f, 0.70f, 1.0f);
|
||
constexpr UIColor kAccent(0.82f, 0.82f, 0.82f, 1.0f);
|
||
constexpr UIColor kSuccess(0.43f, 0.71f, 0.47f, 1.0f);
|
||
constexpr UIColor kWarning(0.78f, 0.60f, 0.30f, 1.0f);
|
||
constexpr UIColor kDanger(0.78f, 0.34f, 0.34f, 1.0f);
|
||
constexpr UIColor kButtonEnabled(0.31f, 0.31f, 0.31f, 1.0f);
|
||
constexpr UIColor kButtonDisabled(0.24f, 0.24f, 0.24f, 1.0f);
|
||
constexpr UIColor kButtonBorder(0.42f, 0.42f, 0.42f, 1.0f);
|
||
constexpr UIColor kPanelRowBg(0.17f, 0.17f, 0.17f, 1.0f);
|
||
|
||
enum class ActionId : unsigned char {
|
||
HideActive = 0,
|
||
SaveLayout,
|
||
CloseDocB,
|
||
LoadLayout,
|
||
ActivateDetails,
|
||
LoadInvalid,
|
||
Reset
|
||
};
|
||
|
||
struct ButtonState {
|
||
ActionId action = ActionId::HideActive;
|
||
std::string label = {};
|
||
UIRect rect = {};
|
||
bool enabled = false;
|
||
};
|
||
|
||
std::filesystem::path ResolveRepoRootPath() {
|
||
std::string root = XCENGINE_EDITOR_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();
|
||
}
|
||
|
||
UIEditorPanelRegistry BuildPanelRegistry() {
|
||
UIEditorPanelRegistry registry = {};
|
||
registry.panels = {
|
||
{ "doc-a", "Document A", {}, true, true, true },
|
||
{ "doc-b", "Document B", {}, true, true, true },
|
||
{ "details", "Details", {}, true, true, true }
|
||
};
|
||
return registry;
|
||
}
|
||
|
||
UIEditorWorkspaceModel BuildWorkspace() {
|
||
UIEditorWorkspaceModel workspace = {};
|
||
workspace.root = BuildUIEditorWorkspaceSplit(
|
||
"root-split",
|
||
UIEditorWorkspaceSplitAxis::Horizontal,
|
||
0.66f,
|
||
BuildUIEditorWorkspaceTabStack(
|
||
"document-tabs",
|
||
{
|
||
BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true),
|
||
BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true)
|
||
},
|
||
0u),
|
||
BuildUIEditorWorkspacePanel("details-node", "details", "Details", true));
|
||
workspace.activePanelId = "doc-a";
|
||
return workspace;
|
||
}
|
||
|
||
bool ContainsPoint(const UIRect& rect, float x, float y) {
|
||
return x >= rect.x &&
|
||
x <= rect.x + rect.width &&
|
||
y >= rect.y &&
|
||
y <= rect.y + rect.height;
|
||
}
|
||
|
||
std::string JoinVisiblePanelIds(
|
||
const UIEditorWorkspaceModel& workspace,
|
||
const UIEditorWorkspaceSession& session) {
|
||
const auto panels = CollectUIEditorWorkspaceVisiblePanels(workspace, session);
|
||
if (panels.empty()) {
|
||
return "(none)";
|
||
}
|
||
|
||
std::ostringstream stream;
|
||
for (std::size_t index = 0; index < panels.size(); ++index) {
|
||
if (index > 0u) {
|
||
stream << ", ";
|
||
}
|
||
stream << panels[index].panelId;
|
||
}
|
||
return stream.str();
|
||
}
|
||
|
||
std::string DescribePanelState(
|
||
const UIEditorWorkspaceSession& session,
|
||
std::string_view panelId,
|
||
std::string_view displayName) {
|
||
const auto* state = FindUIEditorPanelSessionState(session, panelId);
|
||
if (state == nullptr) {
|
||
return std::string(displayName) + ": missing";
|
||
}
|
||
|
||
std::string visibility = {};
|
||
if (!state->open) {
|
||
visibility = "closed";
|
||
} else if (!state->visible) {
|
||
visibility = "hidden";
|
||
} else {
|
||
visibility = "visible";
|
||
}
|
||
|
||
return std::string(displayName) + ": " + visibility;
|
||
}
|
||
|
||
UIColor ResolvePanelStateColor(
|
||
const UIEditorWorkspaceSession& session,
|
||
std::string_view panelId) {
|
||
const auto* state = FindUIEditorPanelSessionState(session, panelId);
|
||
if (state == nullptr) {
|
||
return kDanger;
|
||
}
|
||
|
||
if (!state->open) {
|
||
return kDanger;
|
||
}
|
||
|
||
if (!state->visible) {
|
||
return kWarning;
|
||
}
|
||
|
||
return kSuccess;
|
||
}
|
||
|
||
void DrawCard(
|
||
UIDrawList& drawList,
|
||
const UIRect& rect,
|
||
std::string_view title,
|
||
std::string_view subtitle = {}) {
|
||
drawList.AddFilledRect(rect, kCardBg, 12.0f);
|
||
drawList.AddRectOutline(rect, kCardBorder, 1.0f, 12.0f);
|
||
drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 16.0f), std::string(title), kTextPrimary, 17.0f);
|
||
if (!subtitle.empty()) {
|
||
drawList.AddText(UIPoint(rect.x + 18.0f, rect.y + 42.0f), std::string(subtitle), kTextMuted, 12.0f);
|
||
}
|
||
}
|
||
|
||
std::string BuildSerializedPreview(std::string_view serializedLayout) {
|
||
if (serializedLayout.empty()) {
|
||
return "尚未保存布局";
|
||
}
|
||
|
||
std::istringstream stream{ std::string(serializedLayout) };
|
||
std::ostringstream preview = {};
|
||
std::string line = {};
|
||
int lineCount = 0;
|
||
while (std::getline(stream, line) && lineCount < 4) {
|
||
if (!line.empty() && line.back() == '\r') {
|
||
line.pop_back();
|
||
}
|
||
if (line.empty()) {
|
||
continue;
|
||
}
|
||
if (lineCount > 0) {
|
||
preview << " | ";
|
||
}
|
||
preview << line;
|
||
++lineCount;
|
||
}
|
||
|
||
return preview.str();
|
||
}
|
||
|
||
std::string ReplaceActiveRecord(
|
||
std::string serializedLayout,
|
||
std::string_view replacementPanelId) {
|
||
const std::size_t activeStart = serializedLayout.find("active ");
|
||
if (activeStart == std::string::npos) {
|
||
return {};
|
||
}
|
||
|
||
const std::size_t lineEnd = serializedLayout.find('\n', activeStart);
|
||
std::ostringstream replacement = {};
|
||
replacement << "active " << std::quoted(std::string(replacementPanelId));
|
||
serializedLayout.replace(
|
||
activeStart,
|
||
lineEnd == std::string::npos ? serializedLayout.size() - activeStart : lineEnd - activeStart,
|
||
replacement.str());
|
||
return serializedLayout;
|
||
}
|
||
|
||
UIColor ResolveCommandStatusColor(UIEditorWorkspaceCommandStatus status) {
|
||
switch (status) {
|
||
case UIEditorWorkspaceCommandStatus::Changed:
|
||
return kSuccess;
|
||
case UIEditorWorkspaceCommandStatus::NoOp:
|
||
return kWarning;
|
||
case UIEditorWorkspaceCommandStatus::Rejected:
|
||
return kDanger;
|
||
}
|
||
|
||
return kTextMuted;
|
||
}
|
||
|
||
UIColor ResolveLayoutStatusColor(UIEditorWorkspaceLayoutOperationStatus status) {
|
||
switch (status) {
|
||
case UIEditorWorkspaceLayoutOperationStatus::Changed:
|
||
return kSuccess;
|
||
case UIEditorWorkspaceLayoutOperationStatus::NoOp:
|
||
return kWarning;
|
||
case UIEditorWorkspaceLayoutOperationStatus::Rejected:
|
||
return kDanger;
|
||
}
|
||
|
||
return kTextMuted;
|
||
}
|
||
|
||
class ScenarioApp {
|
||
public:
|
||
int 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);
|
||
}
|
||
|
||
private:
|
||
static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) {
|
||
if (message == WM_NCCREATE) {
|
||
const auto* createStruct = reinterpret_cast<CREATESTRUCTW*>(lParam);
|
||
auto* app = reinterpret_cast<ScenarioApp*>(createStruct->lpCreateParams);
|
||
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(app));
|
||
return TRUE;
|
||
}
|
||
|
||
auto* app = reinterpret_cast<ScenarioApp*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
|
||
switch (message) {
|
||
case WM_SIZE:
|
||
if (app != nullptr && wParam != SIZE_MINIMIZED) {
|
||
app->OnResize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
|
||
}
|
||
return 0;
|
||
case WM_PAINT:
|
||
if (app != nullptr) {
|
||
PAINTSTRUCT paintStruct = {};
|
||
BeginPaint(hwnd, &paintStruct);
|
||
app->RenderFrame();
|
||
EndPaint(hwnd, &paintStruct);
|
||
return 0;
|
||
}
|
||
break;
|
||
case WM_LBUTTONUP:
|
||
if (app != nullptr) {
|
||
app->HandleClick(
|
||
static_cast<float>(GET_X_LPARAM(lParam)),
|
||
static_cast<float>(GET_Y_LPARAM(lParam)));
|
||
return 0;
|
||
}
|
||
break;
|
||
case WM_KEYDOWN:
|
||
case WM_SYSKEYDOWN:
|
||
if (app != nullptr) {
|
||
if (wParam == VK_F12) {
|
||
app->m_autoScreenshot.RequestCapture("manual_f12");
|
||
} else {
|
||
app->HandleShortcut(static_cast<UINT>(wParam));
|
||
}
|
||
return 0;
|
||
}
|
||
break;
|
||
case WM_ERASEBKGND:
|
||
return 1;
|
||
case WM_DESTROY:
|
||
if (app != nullptr) {
|
||
app->m_hwnd = nullptr;
|
||
}
|
||
PostQuitMessage(0);
|
||
return 0;
|
||
default:
|
||
break;
|
||
}
|
||
|
||
return DefWindowProcW(hwnd, message, wParam, lParam);
|
||
}
|
||
|
||
bool Initialize(HINSTANCE hInstance, int nCmdShow) {
|
||
m_hInstance = hInstance;
|
||
ResetScenario();
|
||
|
||
WNDCLASSEXW windowClass = {};
|
||
windowClass.cbSize = sizeof(windowClass);
|
||
windowClass.style = CS_HREDRAW | CS_VREDRAW;
|
||
windowClass.lpfnWndProc = &ScenarioApp::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,
|
||
1360,
|
||
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_autoScreenshot.Initialize(
|
||
(ResolveRepoRootPath() / "tests/UI/Editor/integration/state/layout_persistence/captures")
|
||
.lexically_normal());
|
||
return true;
|
||
}
|
||
|
||
void Shutdown() {
|
||
m_autoScreenshot.Shutdown();
|
||
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 ResetScenario() {
|
||
m_controller =
|
||
BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace());
|
||
m_savedSnapshot = {};
|
||
m_savedSerializedLayout.clear();
|
||
m_hasSavedLayout = false;
|
||
SetCustomResult(
|
||
"等待操作",
|
||
"Pending",
|
||
"先点 `1 Hide Active`,再点 `2 Save Layout`;保存后继续改状态,再用 `4 Load Layout` 检查恢复。");
|
||
}
|
||
|
||
void OnResize(UINT width, UINT height) {
|
||
if (width == 0 || height == 0) {
|
||
return;
|
||
}
|
||
|
||
m_renderer.Resize(width, height);
|
||
}
|
||
|
||
void 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));
|
||
|
||
UIDrawData drawData = {};
|
||
BuildDrawData(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 HandleClick(float x, float y) {
|
||
for (const ButtonState& button : m_buttons) {
|
||
if (button.enabled && ContainsPoint(button.rect, x, y)) {
|
||
DispatchAction(button.action);
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
void HandleShortcut(UINT keyCode) {
|
||
switch (keyCode) {
|
||
case '1':
|
||
DispatchAction(ActionId::HideActive);
|
||
break;
|
||
case '2':
|
||
DispatchAction(ActionId::SaveLayout);
|
||
break;
|
||
case '3':
|
||
DispatchAction(ActionId::CloseDocB);
|
||
break;
|
||
case '4':
|
||
DispatchAction(ActionId::LoadLayout);
|
||
break;
|
||
case '5':
|
||
DispatchAction(ActionId::ActivateDetails);
|
||
break;
|
||
case '6':
|
||
DispatchAction(ActionId::LoadInvalid);
|
||
break;
|
||
case 'R':
|
||
DispatchAction(ActionId::Reset);
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
|
||
InvalidateRect(m_hwnd, nullptr, FALSE);
|
||
}
|
||
|
||
void DispatchAction(ActionId action) {
|
||
switch (action) {
|
||
case ActionId::HideActive: {
|
||
UIEditorWorkspaceCommand command = {};
|
||
command.kind = UIEditorWorkspaceCommandKind::HidePanel;
|
||
command.panelId = m_controller.GetWorkspace().activePanelId;
|
||
SetCommandResult("Hide Active", m_controller.Dispatch(command));
|
||
return;
|
||
}
|
||
|
||
case ActionId::SaveLayout:
|
||
SaveLayout();
|
||
return;
|
||
|
||
case ActionId::CloseDocB:
|
||
SetCommandResult(
|
||
"Close Doc B",
|
||
m_controller.Dispatch({ UIEditorWorkspaceCommandKind::ClosePanel, "doc-b" }));
|
||
return;
|
||
|
||
case ActionId::LoadLayout:
|
||
LoadLayout();
|
||
return;
|
||
|
||
case ActionId::ActivateDetails:
|
||
SetCommandResult(
|
||
"Activate Details",
|
||
m_controller.Dispatch({ UIEditorWorkspaceCommandKind::ActivatePanel, "details" }));
|
||
return;
|
||
|
||
case ActionId::LoadInvalid:
|
||
LoadInvalidLayout();
|
||
return;
|
||
|
||
case ActionId::Reset:
|
||
SetCommandResult(
|
||
"Reset",
|
||
m_controller.Dispatch({ UIEditorWorkspaceCommandKind::ResetWorkspace, {} }));
|
||
return;
|
||
}
|
||
}
|
||
|
||
void SaveLayout() {
|
||
const auto validation = m_controller.ValidateState();
|
||
if (!validation.IsValid()) {
|
||
SetCustomResult(
|
||
"Save Layout",
|
||
"Rejected",
|
||
"当前 controller 状态非法,不能保存布局:" + validation.message);
|
||
return;
|
||
}
|
||
|
||
const UIEditorWorkspaceLayoutSnapshot snapshot = m_controller.CaptureLayoutSnapshot();
|
||
const std::string serialized = SerializeUIEditorWorkspaceLayoutSnapshot(snapshot);
|
||
const bool changed = !m_hasSavedLayout || serialized != m_savedSerializedLayout;
|
||
|
||
m_savedSnapshot = snapshot;
|
||
m_savedSerializedLayout = serialized;
|
||
m_hasSavedLayout = true;
|
||
SetCustomResult(
|
||
"Save Layout",
|
||
changed ? "Saved" : "NoOp",
|
||
changed
|
||
? "已更新保存的布局快照。接下来继续改状态,再点 `Load Layout` 检查恢复。"
|
||
: "保存内容与当前已保存布局一致。");
|
||
}
|
||
|
||
void LoadLayout() {
|
||
if (!m_hasSavedLayout) {
|
||
SetCustomResult("Load Layout", "Rejected", "当前还没有保存过布局。");
|
||
return;
|
||
}
|
||
|
||
SetLayoutResult("Load Layout", m_controller.RestoreSerializedLayout(m_savedSerializedLayout));
|
||
}
|
||
|
||
void LoadInvalidLayout() {
|
||
const auto validation = m_controller.ValidateState();
|
||
if (!validation.IsValid()) {
|
||
SetCustomResult(
|
||
"Load Invalid",
|
||
"Rejected",
|
||
"当前 controller 状态非法,无法构造 invalid payload:" + validation.message);
|
||
return;
|
||
}
|
||
|
||
const std::string source =
|
||
m_hasSavedLayout
|
||
? m_savedSerializedLayout
|
||
: SerializeUIEditorWorkspaceLayoutSnapshot(m_controller.CaptureLayoutSnapshot());
|
||
const std::string invalidSerialized = ReplaceActiveRecord(source, "missing-panel");
|
||
if (invalidSerialized.empty()) {
|
||
SetCustomResult("Load Invalid", "Rejected", "构造 invalid payload 失败。");
|
||
return;
|
||
}
|
||
|
||
SetLayoutResult("Load Invalid", m_controller.RestoreSerializedLayout(invalidSerialized));
|
||
}
|
||
|
||
void SetCommandResult(
|
||
std::string actionName,
|
||
const UIEditorWorkspaceCommandResult& result) {
|
||
m_lastActionName = std::move(actionName);
|
||
m_lastStatusLabel = std::string(GetUIEditorWorkspaceCommandStatusName(result.status));
|
||
m_lastMessage = result.message;
|
||
m_lastStatusColor = ResolveCommandStatusColor(result.status);
|
||
}
|
||
|
||
void SetLayoutResult(
|
||
std::string actionName,
|
||
const UIEditorWorkspaceLayoutOperationResult& result) {
|
||
m_lastActionName = std::move(actionName);
|
||
m_lastStatusLabel = std::string(GetUIEditorWorkspaceLayoutOperationStatusName(result.status));
|
||
m_lastMessage = result.message;
|
||
m_lastStatusColor = ResolveLayoutStatusColor(result.status);
|
||
}
|
||
|
||
void SetCustomResult(
|
||
std::string actionName,
|
||
std::string statusLabel,
|
||
std::string message) {
|
||
m_lastActionName = std::move(actionName);
|
||
m_lastStatusLabel = std::move(statusLabel);
|
||
m_lastMessage = std::move(message);
|
||
if (m_lastStatusLabel == "Rejected") {
|
||
m_lastStatusColor = kDanger;
|
||
} else if (m_lastStatusLabel == "NoOp") {
|
||
m_lastStatusColor = kWarning;
|
||
} else if (m_lastStatusLabel == "Pending") {
|
||
m_lastStatusColor = kTextMuted;
|
||
} else {
|
||
m_lastStatusColor = kSuccess;
|
||
}
|
||
}
|
||
|
||
bool IsButtonEnabled(ActionId action) const {
|
||
const UIEditorWorkspaceModel& workspace = m_controller.GetWorkspace();
|
||
const UIEditorWorkspaceSession& session = m_controller.GetSession();
|
||
|
||
switch (action) {
|
||
case ActionId::HideActive: {
|
||
if (workspace.activePanelId.empty()) {
|
||
return false;
|
||
}
|
||
const auto* state = FindUIEditorPanelSessionState(session, workspace.activePanelId);
|
||
return state != nullptr && state->open && state->visible;
|
||
}
|
||
|
||
case ActionId::SaveLayout:
|
||
return m_controller.ValidateState().IsValid();
|
||
|
||
case ActionId::CloseDocB: {
|
||
const auto* state = FindUIEditorPanelSessionState(session, "doc-b");
|
||
return state != nullptr && state->open;
|
||
}
|
||
|
||
case ActionId::LoadLayout:
|
||
return m_hasSavedLayout;
|
||
|
||
case ActionId::ActivateDetails: {
|
||
const auto* state = FindUIEditorPanelSessionState(session, "details");
|
||
return state != nullptr &&
|
||
state->open &&
|
||
state->visible &&
|
||
workspace.activePanelId != "details";
|
||
}
|
||
|
||
case ActionId::LoadInvalid:
|
||
return m_controller.ValidateState().IsValid();
|
||
|
||
case ActionId::Reset:
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
void DrawPanelStateRows(
|
||
UIDrawList& drawList,
|
||
float startX,
|
||
float startY,
|
||
float width,
|
||
const UIEditorWorkspaceSession& session,
|
||
std::string_view activePanelId) {
|
||
const std::vector<std::pair<std::string, std::string>> panelDefs = {
|
||
{ "doc-a", "Document A" },
|
||
{ "doc-b", "Document B" },
|
||
{ "details", "Details" }
|
||
};
|
||
|
||
float rowY = startY;
|
||
for (const auto& [panelId, label] : panelDefs) {
|
||
const UIRect rowRect(startX, rowY, width, 54.0f);
|
||
drawList.AddFilledRect(rowRect, kPanelRowBg, 8.0f);
|
||
drawList.AddRectOutline(rowRect, kCardBorder, 1.0f, 8.0f);
|
||
drawList.AddFilledRect(
|
||
UIRect(rowRect.x + 12.0f, rowRect.y + 15.0f, 10.0f, 10.0f),
|
||
ResolvePanelStateColor(session, panelId),
|
||
5.0f);
|
||
drawList.AddText(
|
||
UIPoint(rowRect.x + 32.0f, rowRect.y + 11.0f),
|
||
DescribePanelState(session, panelId, label),
|
||
kTextPrimary,
|
||
14.0f);
|
||
const bool active = activePanelId == panelId;
|
||
drawList.AddText(
|
||
UIPoint(rowRect.x + 32.0f, rowRect.y + 31.0f),
|
||
active ? "active = true" : "active = false",
|
||
active ? kAccent : kTextMuted,
|
||
12.0f);
|
||
rowY += 64.0f;
|
||
}
|
||
}
|
||
|
||
void BuildDrawData(UIDrawData& drawData, float width, float height) {
|
||
const UIEditorWorkspaceModel& workspace = m_controller.GetWorkspace();
|
||
const UIEditorWorkspaceSession& session = m_controller.GetSession();
|
||
const auto validation = m_controller.ValidateState();
|
||
|
||
UIDrawList& drawList = drawData.EmplaceDrawList("Editor Layout Persistence");
|
||
drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg);
|
||
|
||
const float margin = 20.0f;
|
||
const UIRect headerRect(margin, margin, width - margin * 2.0f, 178.0f);
|
||
const UIRect actionRect(margin, headerRect.y + headerRect.height + 16.0f, 320.0f, height - 254.0f);
|
||
const UIRect stateRect(actionRect.x + actionRect.width + 16.0f, actionRect.y, width - actionRect.width - margin * 2.0f - 16.0f, height - 254.0f);
|
||
const UIRect footerRect(margin, height - 100.0f, width - margin * 2.0f, 80.0f);
|
||
|
||
DrawCard(drawList, headerRect, "测试功能:Editor Layout Persistence", "只验证 Save / Load / Load Invalid / Reset 的布局恢复链路;不验证业务面板。");
|
||
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 70.0f), "1. 点 `1 Hide Active`,把 Document A 隐藏;active 应切到 `doc-b`,visible 应变成 `doc-b, details`。", kTextPrimary, 13.0f);
|
||
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 92.0f), "2. 点 `2 Save Layout` 保存当前布局;右侧 Saved 摘要必须记录刚才的 active/visible。", kTextPrimary, 13.0f);
|
||
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 114.0f), "3. 再点 `3 Close Doc B` 或 `5 Activate Details` 改状态,然后点 `4 Load Layout`;当前状态必须恢复到保存时。", kTextPrimary, 13.0f);
|
||
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 136.0f), "4. 点 `6 Load Invalid`,Result 必须是 Rejected,且当前 active/visible 不得被污染。", kTextPrimary, 13.0f);
|
||
drawList.AddText(UIPoint(headerRect.x + 18.0f, headerRect.y + 158.0f), "5. `R Reset` 必须回到基线 active=doc-a;按 `F12` 保存当前窗口截图。", kTextPrimary, 13.0f);
|
||
|
||
DrawCard(drawList, actionRect, "操作区", "这里只保留这一步需要检查的布局保存/恢复动作。");
|
||
DrawCard(drawList, stateRect, "状态摘要", "左侧看 Current,右侧看 Saved;重点检查 active、visible 和坏数据恢复。");
|
||
DrawCard(drawList, footerRect, "最近结果", "显示最近一次操作、状态和当前 validation。");
|
||
|
||
m_buttons.clear();
|
||
const std::vector<std::pair<ActionId, std::string>> buttonDefs = {
|
||
{ ActionId::HideActive, "1 Hide Active" },
|
||
{ ActionId::SaveLayout, "2 Save Layout" },
|
||
{ ActionId::CloseDocB, "3 Close Doc B" },
|
||
{ ActionId::LoadLayout, "4 Load Layout" },
|
||
{ ActionId::ActivateDetails, "5 Activate Details" },
|
||
{ ActionId::LoadInvalid, "6 Load Invalid" },
|
||
{ ActionId::Reset, "R Reset" }
|
||
};
|
||
|
||
float buttonY = actionRect.y + 72.0f;
|
||
for (const auto& [action, label] : buttonDefs) {
|
||
ButtonState button = {};
|
||
button.action = action;
|
||
button.label = label;
|
||
button.rect = UIRect(actionRect.x + 18.0f, buttonY, actionRect.width - 36.0f, 46.0f);
|
||
button.enabled = IsButtonEnabled(action);
|
||
m_buttons.push_back(button);
|
||
|
||
drawList.AddFilledRect(
|
||
button.rect,
|
||
button.enabled ? kButtonEnabled : kButtonDisabled,
|
||
8.0f);
|
||
drawList.AddRectOutline(button.rect, kButtonBorder, 1.0f, 8.0f);
|
||
drawList.AddText(
|
||
UIPoint(button.rect.x + 14.0f, button.rect.y + 13.0f),
|
||
button.label,
|
||
button.enabled ? kTextPrimary : kTextMuted,
|
||
13.0f);
|
||
buttonY += 58.0f;
|
||
}
|
||
|
||
const float columnGap = 20.0f;
|
||
const float contentLeft = stateRect.x + 18.0f;
|
||
const float columnWidth = (stateRect.width - 36.0f - columnGap) * 0.5f;
|
||
const float rightColumnX = contentLeft + columnWidth + columnGap;
|
||
|
||
drawList.AddText(UIPoint(contentLeft, stateRect.y + 70.0f), "Current", kAccent, 15.0f);
|
||
drawList.AddText(UIPoint(contentLeft, stateRect.y + 96.0f), "active panel", kTextMuted, 12.0f);
|
||
drawList.AddText(UIPoint(contentLeft, stateRect.y + 116.0f), workspace.activePanelId.empty() ? "(none)" : workspace.activePanelId, kTextPrimary, 14.0f);
|
||
drawList.AddText(UIPoint(contentLeft, stateRect.y + 148.0f), "visible panels", kTextMuted, 12.0f);
|
||
drawList.AddText(UIPoint(contentLeft, stateRect.y + 168.0f), JoinVisiblePanelIds(workspace, session), kTextPrimary, 14.0f);
|
||
drawList.AddText(
|
||
UIPoint(contentLeft, stateRect.y + 198.0f),
|
||
validation.IsValid() ? "validation = OK" : "validation = " + validation.message,
|
||
validation.IsValid() ? kSuccess : kDanger,
|
||
12.0f);
|
||
DrawPanelStateRows(drawList, contentLeft, stateRect.y + 236.0f, columnWidth, session, workspace.activePanelId);
|
||
|
||
drawList.AddText(UIPoint(rightColumnX, stateRect.y + 70.0f), "Saved", kAccent, 15.0f);
|
||
if (!m_hasSavedLayout) {
|
||
drawList.AddText(UIPoint(rightColumnX, stateRect.y + 104.0f), "尚未执行 Save Layout。", kTextMuted, 13.0f);
|
||
drawList.AddText(UIPoint(rightColumnX, stateRect.y + 128.0f), "先把状态改掉,再保存,这样 Load 才有恢复意义。", kTextMuted, 12.0f);
|
||
} else {
|
||
drawList.AddText(UIPoint(rightColumnX, stateRect.y + 96.0f), "active panel", kTextMuted, 12.0f);
|
||
drawList.AddText(UIPoint(rightColumnX, stateRect.y + 116.0f), m_savedSnapshot.workspace.activePanelId.empty() ? "(none)" : m_savedSnapshot.workspace.activePanelId, kTextPrimary, 14.0f);
|
||
drawList.AddText(UIPoint(rightColumnX, stateRect.y + 148.0f), "visible panels", kTextMuted, 12.0f);
|
||
drawList.AddText(
|
||
UIPoint(rightColumnX, stateRect.y + 168.0f),
|
||
JoinVisiblePanelIds(m_savedSnapshot.workspace, m_savedSnapshot.session),
|
||
kTextPrimary,
|
||
14.0f);
|
||
drawList.AddText(UIPoint(rightColumnX, stateRect.y + 198.0f), "payload preview", kTextMuted, 12.0f);
|
||
drawList.AddText(
|
||
UIPoint(rightColumnX, stateRect.y + 220.0f),
|
||
BuildSerializedPreview(m_savedSerializedLayout),
|
||
kTextPrimary,
|
||
12.0f);
|
||
}
|
||
|
||
drawList.AddText(
|
||
UIPoint(footerRect.x + 18.0f, footerRect.y + 28.0f),
|
||
"Last operation: " + m_lastActionName + " | Result: " + m_lastStatusLabel,
|
||
m_lastStatusColor,
|
||
13.0f);
|
||
drawList.AddText(UIPoint(footerRect.x + 18.0f, footerRect.y + 48.0f), m_lastMessage, kTextPrimary, 12.0f);
|
||
drawList.AddText(
|
||
UIPoint(footerRect.x + 18.0f, footerRect.y + 66.0f),
|
||
validation.IsValid() ? "Validation: OK" : "Validation: " + validation.message,
|
||
validation.IsValid() ? kSuccess : kDanger,
|
||
12.0f);
|
||
|
||
const std::string captureSummary =
|
||
m_autoScreenshot.HasPendingCapture()
|
||
? "截图排队中..."
|
||
: (m_autoScreenshot.GetLastCaptureSummary().empty()
|
||
? std::string("F12 -> tests/UI/Editor/integration/state/layout_persistence/captures/")
|
||
: m_autoScreenshot.GetLastCaptureSummary());
|
||
drawList.AddText(UIPoint(footerRect.x + 760.0f, footerRect.y + 66.0f), captureSummary, kTextMuted, 12.0f);
|
||
}
|
||
|
||
HWND m_hwnd = nullptr;
|
||
HINSTANCE m_hInstance = nullptr;
|
||
ATOM m_windowClassAtom = 0;
|
||
NativeRenderer m_renderer = {};
|
||
AutoScreenshotController m_autoScreenshot = {};
|
||
UIEditorWorkspaceController m_controller = {};
|
||
UIEditorWorkspaceLayoutSnapshot m_savedSnapshot = {};
|
||
std::string m_savedSerializedLayout = {};
|
||
bool m_hasSavedLayout = false;
|
||
std::vector<ButtonState> m_buttons = {};
|
||
std::string m_lastActionName = {};
|
||
std::string m_lastStatusLabel = {};
|
||
std::string m_lastMessage = {};
|
||
UIColor m_lastStatusColor = kTextMuted;
|
||
};
|
||
|
||
} // namespace
|
||
|
||
int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) {
|
||
ScenarioApp app;
|
||
return app.Run(hInstance, nCmdShow);
|
||
}
|