Add dock host interaction contract validation

This commit is contained in:
2026-04-07 10:41:39 +08:00
parent f31fece2ce
commit ce1995659a
12 changed files with 1430 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
#pragma once
#include <XCEditor/Core/UIEditorWorkspaceController.h>
#include <XCEditor/Widgets/UIEditorDockHost.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
#include <string>
#include <vector>
namespace XCEngine::UI::Editor {
struct UIEditorDockHostInteractionState {
Widgets::UIEditorDockHostState dockHostState = {};
::XCEngine::UI::Widgets::UISplitterDragState splitterDragState = {};
::XCEngine::UI::UIPoint pointerPosition = {};
bool hasPointerPosition = false;
};
struct UIEditorDockHostInteractionResult {
bool consumed = false;
bool commandExecuted = false;
bool layoutChanged = false;
bool requestPointerCapture = false;
bool releasePointerCapture = false;
Widgets::UIEditorDockHostHitTarget hitTarget = {};
std::string activeSplitterNodeId = {};
UIEditorWorkspaceCommandResult commandResult = {};
UIEditorWorkspaceLayoutOperationResult layoutResult = {};
};
struct UIEditorDockHostInteractionFrame {
Widgets::UIEditorDockHostLayout layout = {};
UIEditorDockHostInteractionResult result = {};
bool focused = false;
};
UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction(
UIEditorDockHostInteractionState& state,
UIEditorWorkspaceController& controller,
const ::XCEngine::UI::UIRect& bounds,
const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents,
const Widgets::UIEditorDockHostMetrics& metrics = {});
} // namespace XCEngine::UI::Editor

View File

@@ -102,6 +102,9 @@ public:
const UIEditorWorkspaceLayoutSnapshot& snapshot);
UIEditorWorkspaceLayoutOperationResult RestoreSerializedLayout(
std::string_view serializedLayout);
UIEditorWorkspaceLayoutOperationResult SetSplitRatio(
std::string_view nodeId,
float splitRatio);
UIEditorWorkspaceCommandResult Dispatch(const UIEditorWorkspaceCommand& command);
private:

View File

@@ -0,0 +1,304 @@
#include <XCEditor/Core/UIEditorDockHostInteraction.h>
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
#include <utility>
namespace XCEngine::UI::Editor {
namespace {
using ::XCEngine::UI::UIInputEvent;
using ::XCEngine::UI::UIInputEventType;
using ::XCEngine::UI::UIRect;
using ::XCEngine::UI::Widgets::BeginUISplitterDrag;
using ::XCEngine::UI::Widgets::EndUISplitterDrag;
using ::XCEngine::UI::Widgets::UpdateUISplitterDrag;
using Widgets::BuildUIEditorDockHostLayout;
using Widgets::FindUIEditorDockHostSplitterLayout;
using Widgets::HitTestUIEditorDockHost;
using Widgets::UIEditorDockHostHitTarget;
using Widgets::UIEditorDockHostHitTargetKind;
bool ShouldUsePointerPosition(const UIInputEvent& event) {
switch (event.type) {
case UIInputEventType::PointerMove:
case UIInputEventType::PointerEnter:
case UIInputEventType::PointerButtonDown:
case UIInputEventType::PointerButtonUp:
return true;
default:
return false;
}
}
UIEditorWorkspaceLayoutOperationResult ApplySplitRatio(
UIEditorWorkspaceController& controller,
std::string_view nodeId,
float splitRatio) {
return controller.SetSplitRatio(nodeId, splitRatio);
}
void SyncHoverTarget(
UIEditorDockHostInteractionState& state,
const Widgets::UIEditorDockHostLayout& layout) {
if (state.splitterDragState.active) {
state.dockHostState.hoveredTarget = {
UIEditorDockHostHitTargetKind::SplitterHandle,
state.dockHostState.activeSplitterNodeId,
{},
Widgets::UIEditorTabStripInvalidIndex
};
return;
}
if (!state.hasPointerPosition) {
state.dockHostState.hoveredTarget = {};
return;
}
state.dockHostState.hoveredTarget =
HitTestUIEditorDockHost(layout, state.pointerPosition);
}
UIEditorWorkspaceCommandResult DispatchPanelCommand(
UIEditorWorkspaceController& controller,
UIEditorWorkspaceCommandKind kind,
std::string panelId) {
UIEditorWorkspaceCommand command = {};
command.kind = kind;
command.panelId = std::move(panelId);
return controller.Dispatch(command);
}
} // namespace
UIEditorDockHostInteractionFrame UpdateUIEditorDockHostInteraction(
UIEditorDockHostInteractionState& state,
UIEditorWorkspaceController& controller,
const UIRect& bounds,
const std::vector<UIInputEvent>& inputEvents,
const Widgets::UIEditorDockHostMetrics& metrics) {
UIEditorDockHostInteractionResult interactionResult = {};
Widgets::UIEditorDockHostLayout layout = BuildUIEditorDockHostLayout(
bounds,
controller.GetPanelRegistry(),
controller.GetWorkspace(),
controller.GetSession(),
state.dockHostState,
metrics);
SyncHoverTarget(state, layout);
for (const UIInputEvent& event : inputEvents) {
if (ShouldUsePointerPosition(event)) {
state.pointerPosition = event.position;
state.hasPointerPosition = true;
} else if (event.type == UIInputEventType::PointerLeave) {
state.hasPointerPosition = false;
}
UIEditorDockHostInteractionResult eventResult = {};
switch (event.type) {
case UIInputEventType::FocusGained:
state.dockHostState.focused = true;
break;
case UIInputEventType::FocusLost:
state.dockHostState.focused = false;
state.dockHostState.hoveredTarget = {};
if (state.splitterDragState.active) {
EndUISplitterDrag(state.splitterDragState);
state.dockHostState.activeSplitterNodeId.clear();
eventResult.consumed = true;
eventResult.releasePointerCapture = true;
}
break;
case UIInputEventType::PointerMove:
case UIInputEventType::PointerEnter:
if (state.splitterDragState.active) {
const auto* splitter = FindUIEditorDockHostSplitterLayout(
layout,
state.dockHostState.activeSplitterNodeId);
if (splitter != nullptr) {
::XCEngine::UI::Layout::UISplitterLayoutResult draggedLayout = {};
if (UpdateUISplitterDrag(
state.splitterDragState,
state.pointerPosition,
draggedLayout)) {
eventResult.layoutResult = ApplySplitRatio(
controller,
state.dockHostState.activeSplitterNodeId,
draggedLayout.splitRatio);
eventResult.layoutChanged =
eventResult.layoutResult.status ==
UIEditorWorkspaceLayoutOperationStatus::Changed;
}
eventResult.consumed = true;
eventResult.hitTarget.kind = UIEditorDockHostHitTargetKind::SplitterHandle;
eventResult.hitTarget.nodeId = state.dockHostState.activeSplitterNodeId;
}
}
break;
case UIInputEventType::PointerLeave:
if (!state.splitterDragState.active) {
state.dockHostState.hoveredTarget = {};
}
break;
case UIInputEventType::PointerButtonDown:
if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) {
break;
}
if (state.dockHostState.hoveredTarget.kind ==
UIEditorDockHostHitTargetKind::SplitterHandle) {
const auto* splitter = FindUIEditorDockHostSplitterLayout(
layout,
state.dockHostState.hoveredTarget.nodeId);
if (splitter != nullptr &&
BeginUISplitterDrag(
1u,
splitter->axis == UIEditorWorkspaceSplitAxis::Horizontal
? ::XCEngine::UI::Layout::UILayoutAxis::Horizontal
: ::XCEngine::UI::Layout::UILayoutAxis::Vertical,
splitter->bounds,
splitter->splitterLayout,
splitter->constraints,
splitter->metrics,
state.pointerPosition,
state.splitterDragState)) {
state.dockHostState.activeSplitterNodeId = splitter->nodeId;
state.dockHostState.focused = true;
eventResult.consumed = true;
eventResult.requestPointerCapture = true;
eventResult.hitTarget = state.dockHostState.hoveredTarget;
eventResult.activeSplitterNodeId = splitter->nodeId;
}
} else {
state.dockHostState.focused =
state.dockHostState.hoveredTarget.kind !=
UIEditorDockHostHitTargetKind::None;
eventResult.consumed = state.dockHostState.focused;
eventResult.hitTarget = state.dockHostState.hoveredTarget;
}
break;
case UIInputEventType::PointerButtonUp:
if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) {
break;
}
if (state.splitterDragState.active) {
::XCEngine::UI::Layout::UISplitterLayoutResult draggedLayout = {};
if (UpdateUISplitterDrag(
state.splitterDragState,
state.pointerPosition,
draggedLayout)) {
eventResult.layoutResult = ApplySplitRatio(
controller,
state.dockHostState.activeSplitterNodeId,
draggedLayout.splitRatio);
eventResult.layoutChanged =
eventResult.layoutResult.status ==
UIEditorWorkspaceLayoutOperationStatus::Changed;
}
EndUISplitterDrag(state.splitterDragState);
eventResult.consumed = true;
eventResult.releasePointerCapture = true;
eventResult.activeSplitterNodeId = state.dockHostState.activeSplitterNodeId;
state.dockHostState.activeSplitterNodeId.clear();
break;
}
eventResult.hitTarget = state.dockHostState.hoveredTarget;
switch (state.dockHostState.hoveredTarget.kind) {
case UIEditorDockHostHitTargetKind::Tab:
case UIEditorDockHostHitTargetKind::PanelHeader:
case UIEditorDockHostHitTargetKind::PanelBody:
case UIEditorDockHostHitTargetKind::PanelFooter:
eventResult.commandResult = DispatchPanelCommand(
controller,
UIEditorWorkspaceCommandKind::ActivatePanel,
state.dockHostState.hoveredTarget.panelId);
eventResult.commandExecuted =
eventResult.commandResult.status !=
UIEditorWorkspaceCommandStatus::Rejected;
eventResult.consumed = true;
state.dockHostState.focused = true;
break;
case UIEditorDockHostHitTargetKind::TabCloseButton:
case UIEditorDockHostHitTargetKind::PanelCloseButton:
eventResult.commandResult = DispatchPanelCommand(
controller,
UIEditorWorkspaceCommandKind::ClosePanel,
state.dockHostState.hoveredTarget.panelId);
eventResult.commandExecuted =
eventResult.commandResult.status !=
UIEditorWorkspaceCommandStatus::Rejected;
eventResult.consumed = true;
state.dockHostState.focused = true;
break;
case UIEditorDockHostHitTargetKind::TabStripBackground:
state.dockHostState.focused = true;
eventResult.consumed = true;
break;
case UIEditorDockHostHitTargetKind::None:
default:
state.dockHostState.focused = false;
break;
}
break;
default:
break;
}
layout = BuildUIEditorDockHostLayout(
bounds,
controller.GetPanelRegistry(),
controller.GetWorkspace(),
controller.GetSession(),
state.dockHostState,
metrics);
SyncHoverTarget(state, layout);
if (eventResult.hitTarget.kind == UIEditorDockHostHitTargetKind::None) {
eventResult.hitTarget = state.dockHostState.hoveredTarget;
}
if (eventResult.consumed ||
eventResult.commandExecuted ||
eventResult.layoutChanged ||
eventResult.requestPointerCapture ||
eventResult.releasePointerCapture ||
eventResult.layoutResult.status != UIEditorWorkspaceLayoutOperationStatus::Rejected ||
eventResult.hitTarget.kind != UIEditorDockHostHitTargetKind::None ||
!eventResult.activeSplitterNodeId.empty()) {
interactionResult = std::move(eventResult);
}
}
layout = BuildUIEditorDockHostLayout(
bounds,
controller.GetPanelRegistry(),
controller.GetWorkspace(),
controller.GetSession(),
state.dockHostState,
metrics);
SyncHoverTarget(state, layout);
if (interactionResult.hitTarget.kind == UIEditorDockHostHitTargetKind::None) {
interactionResult.hitTarget = state.dockHostState.hoveredTarget;
}
return {
std::move(layout),
std::move(interactionResult),
state.dockHostState.focused
};
}
} // namespace XCEngine::UI::Editor

View File

@@ -1,5 +1,6 @@
#include <XCEditor/Core/UIEditorWorkspaceController.h>
#include <cmath>
#include <utility>
namespace XCEngine::UI::Editor {
@@ -240,6 +241,55 @@ UIEditorWorkspaceLayoutOperationResult UIEditorWorkspaceController::RestoreSeria
return RestoreLayoutSnapshot(loadResult.snapshot);
}
UIEditorWorkspaceLayoutOperationResult UIEditorWorkspaceController::SetSplitRatio(
std::string_view nodeId,
float splitRatio) {
const UIEditorWorkspaceControllerValidationResult validation = ValidateState();
if (!validation.IsValid()) {
return BuildLayoutOperationResult(
UIEditorWorkspaceLayoutOperationStatus::Rejected,
"Controller state invalid: " + validation.message);
}
if (nodeId.empty()) {
return BuildLayoutOperationResult(
UIEditorWorkspaceLayoutOperationStatus::Rejected,
"SetSplitRatio requires a split node id.");
}
const UIEditorWorkspaceNode* splitNode = FindUIEditorWorkspaceNode(m_workspace, nodeId);
if (splitNode == nullptr || splitNode->kind != UIEditorWorkspaceNodeKind::Split) {
return BuildLayoutOperationResult(
UIEditorWorkspaceLayoutOperationStatus::Rejected,
"SetSplitRatio target split node is missing.");
}
if (std::fabs(splitNode->splitRatio - splitRatio) <= 0.0001f) {
return BuildLayoutOperationResult(
UIEditorWorkspaceLayoutOperationStatus::NoOp,
"Split ratio already matches the requested value.");
}
const UIEditorWorkspaceModel previousWorkspace = m_workspace;
if (!TrySetUIEditorWorkspaceSplitRatio(m_workspace, nodeId, splitRatio)) {
return BuildLayoutOperationResult(
UIEditorWorkspaceLayoutOperationStatus::Rejected,
"Split ratio update rejected.");
}
const UIEditorWorkspaceControllerValidationResult postValidation = ValidateState();
if (!postValidation.IsValid()) {
m_workspace = previousWorkspace;
return BuildLayoutOperationResult(
UIEditorWorkspaceLayoutOperationStatus::Rejected,
"Split ratio update produced invalid controller state: " + postValidation.message);
}
return BuildLayoutOperationResult(
UIEditorWorkspaceLayoutOperationStatus::Changed,
"Split ratio updated.");
}
UIEditorWorkspaceCommandResult UIEditorWorkspaceController::Dispatch(
const UIEditorWorkspaceCommand& command) {
const UIEditorWorkspaceControllerValidationResult validation = ValidateState();