diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index a0a299c2..9b0b5c81 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(XCUIEditorLib STATIC src/Core/UIEditorWorkspaceModel.cpp src/Core/UIEditorWorkspaceSession.cpp src/Widgets/UIEditorCollectionPrimitives.cpp + src/Widgets/UIEditorDockHost.cpp src/Widgets/UIEditorPanelFrame.cpp src/Widgets/UIEditorTabStrip.cpp ) diff --git a/new_editor/include/XCEditor/Core/UIEditorWorkspaceModel.h b/new_editor/include/XCEditor/Core/UIEditorWorkspaceModel.h index e3d7809f..1043a0ee 100644 --- a/new_editor/include/XCEditor/Core/UIEditorWorkspaceModel.h +++ b/new_editor/include/XCEditor/Core/UIEditorWorkspaceModel.h @@ -100,6 +100,10 @@ bool ContainsUIEditorWorkspacePanel( const UIEditorWorkspaceModel& workspace, std::string_view panelId); +const UIEditorWorkspaceNode* FindUIEditorWorkspaceNode( + const UIEditorWorkspaceModel& workspace, + std::string_view nodeId); + bool AreUIEditorWorkspaceNodesEquivalent( const UIEditorWorkspaceNode& lhs, const UIEditorWorkspaceNode& rhs); @@ -115,4 +119,9 @@ bool TryActivateUIEditorWorkspacePanel( UIEditorWorkspaceModel& workspace, std::string_view panelId); +bool TrySetUIEditorWorkspaceSplitRatio( + UIEditorWorkspaceModel& workspace, + std::string_view nodeId, + float splitRatio); + } // namespace XCEngine::UI::Editor diff --git a/new_editor/include/XCEditor/Widgets/UIEditorDockHost.h b/new_editor/include/XCEditor/Widgets/UIEditorDockHost.h new file mode 100644 index 00000000..79b66bb1 --- /dev/null +++ b/new_editor/include/XCEditor/Widgets/UIEditorDockHost.h @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +enum class UIEditorDockHostHitTargetKind : std::uint8_t { + None = 0, + SplitterHandle, + TabStripBackground, + Tab, + TabCloseButton, + PanelHeader, + PanelBody, + PanelFooter, + PanelCloseButton +}; + +struct UIEditorDockHostHitTarget { + UIEditorDockHostHitTargetKind kind = UIEditorDockHostHitTargetKind::None; + std::string nodeId = {}; + std::string panelId = {}; + std::size_t index = UIEditorTabStripInvalidIndex; +}; + +struct UIEditorDockHostState { + bool focused = false; + UIEditorDockHostHitTarget hoveredTarget = {}; + std::string activeSplitterNodeId = {}; +}; + +struct UIEditorDockHostMetrics { + ::XCEngine::UI::Layout::UISplitterMetrics splitterMetrics = + ::XCEngine::UI::Layout::UISplitterMetrics{ 10.0f, 18.0f }; + UIEditorTabStripMetrics tabStripMetrics = {}; + UIEditorPanelFrameMetrics panelFrameMetrics = {}; + ::XCEngine::UI::UISize minimumStandalonePanelBodySize = + ::XCEngine::UI::UISize(180.0f, 140.0f); + ::XCEngine::UI::UISize minimumTabContentBodySize = + ::XCEngine::UI::UISize(260.0f, 180.0f); + float splitterHandleRounding = 4.0f; + float placeholderLineGap = 22.0f; +}; + +struct UIEditorDockHostPalette { + UIEditorTabStripPalette tabStripPalette = {}; + UIEditorPanelFramePalette panelFramePalette = {}; + ::XCEngine::UI::UIColor splitterColor = + ::XCEngine::UI::UIColor(0.26f, 0.26f, 0.26f, 1.0f); + ::XCEngine::UI::UIColor splitterHoveredColor = + ::XCEngine::UI::UIColor(0.36f, 0.36f, 0.36f, 1.0f); + ::XCEngine::UI::UIColor splitterActiveColor = + ::XCEngine::UI::UIColor(0.86f, 0.86f, 0.86f, 1.0f); + ::XCEngine::UI::UIColor placeholderTitleColor = + ::XCEngine::UI::UIColor(0.94f, 0.94f, 0.94f, 1.0f); + ::XCEngine::UI::UIColor placeholderTextColor = + ::XCEngine::UI::UIColor(0.72f, 0.72f, 0.72f, 1.0f); + ::XCEngine::UI::UIColor placeholderMutedColor = + ::XCEngine::UI::UIColor(0.58f, 0.58f, 0.58f, 1.0f); +}; + +struct UIEditorDockHostTabItemLayout { + std::string panelId = {}; + std::string title = {}; + bool closable = true; + bool active = false; +}; + +struct UIEditorDockHostSplitterLayout { + std::string nodeId = {}; + UIEditorWorkspaceSplitAxis axis = UIEditorWorkspaceSplitAxis::Horizontal; + ::XCEngine::UI::UIRect bounds = {}; + ::XCEngine::UI::UIRect handleHitRect = {}; + ::XCEngine::UI::Layout::UISplitterConstraints constraints = {}; + ::XCEngine::UI::Layout::UISplitterMetrics metrics = {}; + ::XCEngine::UI::Layout::UISplitterLayoutResult splitterLayout = {}; + bool hovered = false; + bool active = false; +}; + +struct UIEditorDockHostPanelLayout { + std::string nodeId = {}; + std::string panelId = {}; + std::string title = {}; + bool active = false; + UIEditorPanelFrameState frameState = {}; + UIEditorPanelFrameLayout frameLayout = {}; +}; + +struct UIEditorDockHostTabStackLayout { + std::string nodeId = {}; + std::string selectedPanelId = {}; + ::XCEngine::UI::UIRect bounds = {}; + std::vector items = {}; + UIEditorTabStripState tabStripState = {}; + UIEditorTabStripLayout tabStripLayout = {}; + UIEditorPanelFrameState contentFrameState = {}; + UIEditorPanelFrameLayout contentFrameLayout = {}; +}; + +struct UIEditorDockHostLayout { + ::XCEngine::UI::UIRect bounds = {}; + std::vector splitters = {}; + std::vector panels = {}; + std::vector tabStacks = {}; +}; + +const UIEditorDockHostSplitterLayout* FindUIEditorDockHostSplitterLayout( + const UIEditorDockHostLayout& layout, + std::string_view nodeId); + +UIEditorDockHostLayout BuildUIEditorDockHostLayout( + const ::XCEngine::UI::UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostState& state = {}, + const UIEditorDockHostMetrics& metrics = {}); + +UIEditorDockHostHitTarget HitTestUIEditorDockHost( + const UIEditorDockHostLayout& layout, + const ::XCEngine::UI::UIPoint& point); + +void AppendUIEditorDockHostBackground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorDockHostLayout& layout, + const UIEditorDockHostPalette& palette = {}, + const UIEditorDockHostMetrics& metrics = {}); + +void AppendUIEditorDockHostForeground( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorDockHostLayout& layout, + const UIEditorDockHostPalette& palette = {}, + const UIEditorDockHostMetrics& metrics = {}); + +void AppendUIEditorDockHost( + ::XCEngine::UI::UIDrawList& drawList, + const ::XCEngine::UI::UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostState& state = {}, + const UIEditorDockHostPalette& palette = {}, + const UIEditorDockHostMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/new_editor/src/Core/UIEditorWorkspaceModel.cpp b/new_editor/src/Core/UIEditorWorkspaceModel.cpp index e5decedb..32875fb3 100644 --- a/new_editor/src/Core/UIEditorWorkspaceModel.cpp +++ b/new_editor/src/Core/UIEditorWorkspaceModel.cpp @@ -50,6 +50,38 @@ const UIEditorWorkspacePanelState* FindPanelRecursive( return nullptr; } +const UIEditorWorkspaceNode* FindNodeRecursive( + const UIEditorWorkspaceNode& node, + std::string_view nodeId) { + if (node.nodeId == nodeId) { + return &node; + } + + for (const UIEditorWorkspaceNode& child : node.children) { + if (const UIEditorWorkspaceNode* found = FindNodeRecursive(child, nodeId)) { + return found; + } + } + + return nullptr; +} + +UIEditorWorkspaceNode* FindMutableNodeRecursive( + UIEditorWorkspaceNode& node, + std::string_view nodeId) { + if (node.nodeId == nodeId) { + return &node; + } + + for (UIEditorWorkspaceNode& child : node.children) { + if (UIEditorWorkspaceNode* found = FindMutableNodeRecursive(child, nodeId)) { + return found; + } + } + + return nullptr; +} + bool TryActivateRecursive( UIEditorWorkspaceNode& node, std::string_view panelId) { @@ -326,6 +358,12 @@ bool ContainsUIEditorWorkspacePanel( return FindPanelRecursive(workspace.root, panelId) != nullptr; } +const UIEditorWorkspaceNode* FindUIEditorWorkspaceNode( + const UIEditorWorkspaceModel& workspace, + std::string_view nodeId) { + return FindNodeRecursive(workspace.root, nodeId); +} + const UIEditorWorkspacePanelState* FindUIEditorWorkspaceActivePanel( const UIEditorWorkspaceModel& workspace) { if (workspace.activePanelId.empty()) { @@ -354,4 +392,25 @@ bool TryActivateUIEditorWorkspacePanel( return true; } +bool TrySetUIEditorWorkspaceSplitRatio( + UIEditorWorkspaceModel& workspace, + std::string_view nodeId, + float splitRatio) { + if (!IsValidSplitRatio(splitRatio)) { + return false; + } + + UIEditorWorkspaceNode* node = FindMutableNodeRecursive(workspace.root, nodeId); + if (node == nullptr || node->kind != UIEditorWorkspaceNodeKind::Split) { + return false; + } + + if (std::fabs(node->splitRatio - splitRatio) <= 0.0001f) { + return false; + } + + node->splitRatio = splitRatio; + return true; +} + } // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Widgets/UIEditorDockHost.cpp b/new_editor/src/Widgets/UIEditorDockHost.cpp new file mode 100644 index 00000000..dd932ffc --- /dev/null +++ b/new_editor/src/Widgets/UIEditorDockHost.cpp @@ -0,0 +1,830 @@ +#include + +#include + +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +namespace { + +using ::XCEngine::UI::UIColor; +using ::XCEngine::UI::UIDrawList; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; +using ::XCEngine::UI::UISize; +using ::XCEngine::UI::Layout::ArrangeUISplitter; +using ::XCEngine::UI::Layout::MeasureSplitterDesiredSize; +using ::XCEngine::UI::Layout::MeasureUITabStrip; +using ::XCEngine::UI::Layout::UITabStripMeasureItem; +using ::XCEngine::UI::Layout::UILayoutAxis; +using ::XCEngine::UI::Widgets::ExpandUISplitterHandleHitRect; + +struct DockMeasureResult { + bool visible = false; + UISize minimumSize = {}; +}; + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +UILayoutAxis ToLayoutAxis(UIEditorWorkspaceSplitAxis axis) { + return axis == UIEditorWorkspaceSplitAxis::Horizontal + ? UILayoutAxis::Horizontal + : UILayoutAxis::Vertical; +} + +float GetMainExtent(const UISize& size, UIEditorWorkspaceSplitAxis axis) { + return axis == UIEditorWorkspaceSplitAxis::Horizontal ? size.width : size.height; +} + +bool IsPointInsideRect( + const UIRect& rect, + const UIPoint& point) { + return point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height; +} + +bool IsPanelOpenAndVisible( + const UIEditorWorkspaceSession& session, + std::string_view panelId) { + const UIEditorPanelSessionState* panelState = + FindUIEditorPanelSessionState(session, panelId); + return panelState != nullptr && panelState->open && panelState->visible; +} + +const UIEditorPanelDescriptor* FindPanelDescriptor( + const UIEditorPanelRegistry& panelRegistry, + std::string_view panelId) { + return FindUIEditorPanelDescriptor(panelRegistry, panelId); +} + +UIEditorPanelFrameMetrics BuildTabContentFrameMetrics( + const UIEditorDockHostMetrics& metrics) { + UIEditorPanelFrameMetrics frameMetrics = metrics.panelFrameMetrics; + frameMetrics.headerHeight = 0.0f; + frameMetrics.footerHeight = 0.0f; + frameMetrics.actionButtonExtent = 0.0f; + frameMetrics.actionInsetX = 0.0f; + frameMetrics.actionGap = 0.0f; + return frameMetrics; +} + +UISize MeasurePanelMinimumSize( + const UIEditorDockHostMetrics& metrics) { + const UIEditorPanelFrameMetrics& frameMetrics = metrics.panelFrameMetrics; + return UISize( + metrics.minimumStandalonePanelBodySize.width + + ClampNonNegative(frameMetrics.contentPadding) * 2.0f, + ClampNonNegative(frameMetrics.headerHeight) + + metrics.minimumStandalonePanelBodySize.height + + ClampNonNegative(frameMetrics.contentPadding) * 2.0f); +} + +UISize MeasureTabContentMinimumSize( + const UIEditorDockHostMetrics& metrics) { + const UIEditorPanelFrameMetrics frameMetrics = BuildTabContentFrameMetrics(metrics); + return UISize( + metrics.minimumTabContentBodySize.width + + ClampNonNegative(frameMetrics.contentPadding) * 2.0f, + metrics.minimumTabContentBodySize.height + + ClampNonNegative(frameMetrics.contentPadding) * 2.0f); +} + +DockMeasureResult MeasureNodeRecursive( + const UIEditorWorkspaceNode& node, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostMetrics& metrics); + +DockMeasureResult MeasureTabStackNode( + const UIEditorWorkspaceNode& node, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostMetrics& metrics) { + std::vector measureItems = {}; + for (const UIEditorWorkspaceNode& child : node.children) { + if (child.kind != UIEditorWorkspaceNodeKind::Panel || + !IsPanelOpenAndVisible(session, child.panel.panelId)) { + continue; + } + + UIEditorTabStripItem item = {}; + item.tabId = child.panel.panelId; + item.title = child.panel.title; + const UIEditorPanelDescriptor* descriptor = + FindPanelDescriptor(panelRegistry, child.panel.panelId); + item.closable = descriptor != nullptr ? descriptor->canClose : true; + + UITabStripMeasureItem measureItem = {}; + measureItem.desiredHeaderLabelWidth = + ResolveUIEditorTabStripDesiredHeaderLabelWidth(item, metrics.tabStripMetrics); + measureItem.desiredContentSize = MeasureTabContentMinimumSize(metrics); + measureItem.minimumContentSize = MeasureTabContentMinimumSize(metrics); + measureItems.push_back(std::move(measureItem)); + } + + if (measureItems.empty()) { + return {}; + } + + const auto measured = MeasureUITabStrip(measureItems, metrics.tabStripMetrics.layoutMetrics); + DockMeasureResult result = {}; + result.visible = true; + result.minimumSize = measured.minimumSize; + return result; +} + +DockMeasureResult MeasureSplitNode( + const UIEditorWorkspaceNode& node, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostMetrics& metrics) { + const DockMeasureResult primary = + MeasureNodeRecursive(node.children[0], panelRegistry, session, metrics); + const DockMeasureResult secondary = + MeasureNodeRecursive(node.children[1], panelRegistry, session, metrics); + + if (!primary.visible && !secondary.visible) { + return {}; + } + + if (!primary.visible) { + return secondary; + } + + if (!secondary.visible) { + return primary; + } + + DockMeasureResult result = {}; + result.visible = true; + result.minimumSize = MeasureSplitterDesiredSize( + ToLayoutAxis(node.splitAxis), + primary.minimumSize, + secondary.minimumSize, + metrics.splitterMetrics.thickness); + return result; +} + +DockMeasureResult MeasureNodeRecursive( + const UIEditorWorkspaceNode& node, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostMetrics& metrics) { + switch (node.kind) { + case UIEditorWorkspaceNodeKind::Panel: { + DockMeasureResult result = {}; + result.visible = IsPanelOpenAndVisible(session, node.panel.panelId); + if (result.visible) { + result.minimumSize = MeasurePanelMinimumSize(metrics); + } + return result; + } + case UIEditorWorkspaceNodeKind::TabStack: + return MeasureTabStackNode(node, panelRegistry, session, metrics); + case UIEditorWorkspaceNodeKind::Split: + return MeasureSplitNode(node, panelRegistry, session, metrics); + } + + return {}; +} + +std::size_t ResolveSelectedVisibleTabIndex( + const UIEditorWorkspaceNode& node, + const std::vector& visibleChildIndices) { + if (visibleChildIndices.empty()) { + return UIEditorTabStripInvalidIndex; + } + + for (std::size_t visibleIndex = 0; visibleIndex < visibleChildIndices.size(); ++visibleIndex) { + if (visibleChildIndices[visibleIndex] == node.selectedTabIndex) { + return visibleIndex; + } + } + + return 0u; +} + +UIEditorTabStripState BuildTabStripState( + const UIEditorDockHostState& state, + std::string_view nodeId, + std::size_t selectedIndex) { + UIEditorTabStripState tabState = {}; + tabState.selectedIndex = selectedIndex; + tabState.focused = state.focused; + + if (state.hoveredTarget.nodeId != nodeId) { + return tabState; + } + + switch (state.hoveredTarget.kind) { + case UIEditorDockHostHitTargetKind::Tab: + tabState.hoveredIndex = state.hoveredTarget.index; + break; + case UIEditorDockHostHitTargetKind::TabCloseButton: + tabState.hoveredIndex = state.hoveredTarget.index; + tabState.closeHoveredIndex = state.hoveredTarget.index; + break; + default: + break; + } + + return tabState; +} + +UIEditorPanelFrameState BuildStandalonePanelFrameState( + const UIEditorDockHostState& state, + const UIEditorWorkspaceModel& workspace, + const UIEditorPanelDescriptor* descriptor, + const UIEditorWorkspaceNode& node) { + UIEditorPanelFrameState frameState = {}; + frameState.active = workspace.activePanelId == node.panel.panelId; + frameState.focused = state.focused && frameState.active; + frameState.closable = descriptor != nullptr ? descriptor->canClose : true; + frameState.pinnable = false; + frameState.showFooter = true; + + if (state.hoveredTarget.panelId != node.panel.panelId) { + return frameState; + } + + switch (state.hoveredTarget.kind) { + case UIEditorDockHostHitTargetKind::PanelHeader: + case UIEditorDockHostHitTargetKind::PanelBody: + case UIEditorDockHostHitTargetKind::PanelFooter: + case UIEditorDockHostHitTargetKind::PanelCloseButton: + frameState.hovered = true; + break; + default: + break; + } + + frameState.closeHovered = + state.hoveredTarget.kind == UIEditorDockHostHitTargetKind::PanelCloseButton; + return frameState; +} + +UIEditorPanelFrameState BuildTabContentFrameState( + const UIEditorDockHostState& state, + const UIEditorWorkspaceModel& workspace, + std::string_view panelId) { + UIEditorPanelFrameState frameState = {}; + frameState.active = workspace.activePanelId == panelId; + frameState.focused = state.focused && frameState.active; + frameState.closable = false; + frameState.pinnable = false; + + if (state.hoveredTarget.panelId != panelId) { + return frameState; + } + + switch (state.hoveredTarget.kind) { + case UIEditorDockHostHitTargetKind::PanelHeader: + case UIEditorDockHostHitTargetKind::PanelBody: + case UIEditorDockHostHitTargetKind::PanelFooter: + frameState.hovered = true; + break; + default: + break; + } + + return frameState; +} + +void LayoutNodeRecursive( + const UIEditorWorkspaceNode& node, + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostState& state, + const UIEditorDockHostMetrics& metrics, + UIEditorDockHostLayout& layout); + +void LayoutPanelNode( + const UIEditorWorkspaceNode& node, + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorDockHostState& state, + const UIEditorDockHostMetrics& metrics, + UIEditorDockHostLayout& layout) { + const UIEditorPanelDescriptor* descriptor = + FindPanelDescriptor(panelRegistry, node.panel.panelId); + + UIEditorDockHostPanelLayout panelLayout = {}; + panelLayout.nodeId = node.nodeId; + panelLayout.panelId = node.panel.panelId; + panelLayout.title = node.panel.title; + panelLayout.active = workspace.activePanelId == node.panel.panelId; + panelLayout.frameState = + BuildStandalonePanelFrameState(state, workspace, descriptor, node); + panelLayout.frameLayout = + BuildUIEditorPanelFrameLayout(bounds, panelLayout.frameState, metrics.panelFrameMetrics); + layout.panels.push_back(std::move(panelLayout)); +} + +void LayoutTabStackNode( + const UIEditorWorkspaceNode& node, + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostState& state, + const UIEditorDockHostMetrics& metrics, + UIEditorDockHostLayout& layout) { + std::vector visibleChildIndices = {}; + visibleChildIndices.reserve(node.children.size()); + + UIEditorDockHostTabStackLayout tabStackLayout = {}; + tabStackLayout.nodeId = node.nodeId; + tabStackLayout.bounds = bounds; + + std::vector tabStripItems = {}; + for (std::size_t childIndex = 0; childIndex < node.children.size(); ++childIndex) { + const UIEditorWorkspaceNode& child = node.children[childIndex]; + if (child.kind != UIEditorWorkspaceNodeKind::Panel || + !IsPanelOpenAndVisible(session, child.panel.panelId)) { + continue; + } + + visibleChildIndices.push_back(childIndex); + + UIEditorDockHostTabItemLayout itemLayout = {}; + itemLayout.panelId = child.panel.panelId; + itemLayout.title = child.panel.title; + itemLayout.active = workspace.activePanelId == child.panel.panelId; + const UIEditorPanelDescriptor* descriptor = + FindPanelDescriptor(panelRegistry, child.panel.panelId); + itemLayout.closable = descriptor != nullptr ? descriptor->canClose : true; + tabStackLayout.items.push_back(itemLayout); + + UIEditorTabStripItem tabItem = {}; + tabItem.tabId = itemLayout.panelId; + tabItem.title = itemLayout.title; + tabItem.closable = itemLayout.closable; + tabStripItems.push_back(std::move(tabItem)); + } + + if (tabStripItems.empty()) { + return; + } + + const std::size_t selectedVisibleIndex = + ResolveSelectedVisibleTabIndex(node, visibleChildIndices); + tabStackLayout.selectedPanelId = + tabStackLayout.items[selectedVisibleIndex].panelId; + tabStackLayout.tabStripState = + BuildTabStripState(state, node.nodeId, selectedVisibleIndex); + tabStackLayout.tabStripLayout = + BuildUIEditorTabStripLayout(bounds, tabStripItems, tabStackLayout.tabStripState, metrics.tabStripMetrics); + tabStackLayout.contentFrameState = + BuildTabContentFrameState(state, workspace, tabStackLayout.selectedPanelId); + tabStackLayout.contentFrameLayout = BuildUIEditorPanelFrameLayout( + tabStackLayout.tabStripLayout.contentRect, + tabStackLayout.contentFrameState, + BuildTabContentFrameMetrics(metrics)); + + layout.tabStacks.push_back(std::move(tabStackLayout)); +} + +void LayoutSplitNode( + const UIEditorWorkspaceNode& node, + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostState& state, + const UIEditorDockHostMetrics& metrics, + UIEditorDockHostLayout& layout) { + const DockMeasureResult primaryMeasure = + MeasureNodeRecursive(node.children[0], panelRegistry, session, metrics); + const DockMeasureResult secondaryMeasure = + MeasureNodeRecursive(node.children[1], panelRegistry, session, metrics); + + if (!primaryMeasure.visible && !secondaryMeasure.visible) { + return; + } + + if (!primaryMeasure.visible) { + LayoutNodeRecursive( + node.children[1], + bounds, + panelRegistry, + workspace, + session, + state, + metrics, + layout); + return; + } + + if (!secondaryMeasure.visible) { + LayoutNodeRecursive( + node.children[0], + bounds, + panelRegistry, + workspace, + session, + state, + metrics, + layout); + return; + } + + UIEditorDockHostSplitterLayout splitterLayout = {}; + splitterLayout.nodeId = node.nodeId; + splitterLayout.axis = node.splitAxis; + splitterLayout.bounds = bounds; + splitterLayout.metrics = metrics.splitterMetrics; + splitterLayout.constraints.primaryMin = + GetMainExtent(primaryMeasure.minimumSize, node.splitAxis); + splitterLayout.constraints.secondaryMin = + GetMainExtent(secondaryMeasure.minimumSize, node.splitAxis); + splitterLayout.splitterLayout = ArrangeUISplitter( + bounds, + ToLayoutAxis(node.splitAxis), + node.splitRatio, + splitterLayout.constraints, + splitterLayout.metrics); + splitterLayout.handleHitRect = ExpandUISplitterHandleHitRect( + splitterLayout.splitterLayout.handleRect, + ToLayoutAxis(node.splitAxis), + (std::max)(0.0f, (splitterLayout.metrics.hitThickness - splitterLayout.metrics.thickness) * 0.5f)); + splitterLayout.hovered = + state.hoveredTarget.kind == UIEditorDockHostHitTargetKind::SplitterHandle && + state.hoveredTarget.nodeId == node.nodeId; + splitterLayout.active = state.activeSplitterNodeId == node.nodeId; + layout.splitters.push_back(splitterLayout); + + LayoutNodeRecursive( + node.children[0], + splitterLayout.splitterLayout.primaryRect, + panelRegistry, + workspace, + session, + state, + metrics, + layout); + LayoutNodeRecursive( + node.children[1], + splitterLayout.splitterLayout.secondaryRect, + panelRegistry, + workspace, + session, + state, + metrics, + layout); +} + +void LayoutNodeRecursive( + const UIEditorWorkspaceNode& node, + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostState& state, + const UIEditorDockHostMetrics& metrics, + UIEditorDockHostLayout& layout) { + switch (node.kind) { + case UIEditorWorkspaceNodeKind::Panel: + if (IsPanelOpenAndVisible(session, node.panel.panelId)) { + LayoutPanelNode(node, bounds, panelRegistry, workspace, state, metrics, layout); + } + return; + case UIEditorWorkspaceNodeKind::TabStack: + LayoutTabStackNode(node, bounds, panelRegistry, workspace, session, state, metrics, layout); + return; + case UIEditorWorkspaceNodeKind::Split: + LayoutSplitNode(node, bounds, panelRegistry, workspace, session, state, metrics, layout); + return; + } +} + +UIEditorDockHostHitTarget MakePanelHitTarget( + UIEditorDockHostHitTargetKind kind, + std::string_view nodeId, + std::string_view panelId) { + UIEditorDockHostHitTarget target = {}; + target.kind = kind; + target.nodeId = std::string(nodeId); + target.panelId = std::string(panelId); + return target; +} + +UIEditorDockHostHitTarget MapPanelFrameHitTarget( + UIEditorPanelFrameHitTarget hitTarget, + std::string_view nodeId, + std::string_view panelId) { + switch (hitTarget) { + case UIEditorPanelFrameHitTarget::Header: + return MakePanelHitTarget(UIEditorDockHostHitTargetKind::PanelHeader, nodeId, panelId); + case UIEditorPanelFrameHitTarget::Body: + return MakePanelHitTarget(UIEditorDockHostHitTargetKind::PanelBody, nodeId, panelId); + case UIEditorPanelFrameHitTarget::Footer: + return MakePanelHitTarget(UIEditorDockHostHitTargetKind::PanelFooter, nodeId, panelId); + case UIEditorPanelFrameHitTarget::CloseButton: + return MakePanelHitTarget(UIEditorDockHostHitTargetKind::PanelCloseButton, nodeId, panelId); + default: + return {}; + } +} + +UIColor ResolveSplitterColor(const UIEditorDockHostSplitterLayout& splitter, const UIEditorDockHostPalette& palette) { + if (splitter.active) { + return palette.splitterActiveColor; + } + + if (splitter.hovered) { + return palette.splitterHoveredColor; + } + + return palette.splitterColor; +} + +void AppendPlaceholderText( + UIDrawList& drawList, + const UIRect& bodyRect, + std::string title, + std::string detailLine, + std::string extraLine, + const UIEditorDockHostPalette& palette, + const UIEditorDockHostMetrics& metrics) { + if (bodyRect.width <= 0.0f || bodyRect.height <= 0.0f) { + return; + } + + drawList.AddText( + UIPoint(bodyRect.x, bodyRect.y), + std::move(title), + palette.placeholderTitleColor, + 16.0f); + drawList.AddText( + UIPoint(bodyRect.x, bodyRect.y + metrics.placeholderLineGap), + std::move(detailLine), + palette.placeholderTextColor, + 12.0f); + drawList.AddText( + UIPoint(bodyRect.x, bodyRect.y + metrics.placeholderLineGap * 2.0f), + std::move(extraLine), + palette.placeholderMutedColor, + 12.0f); +} + +} // namespace + +const UIEditorDockHostSplitterLayout* FindUIEditorDockHostSplitterLayout( + const UIEditorDockHostLayout& layout, + std::string_view nodeId) { + for (const UIEditorDockHostSplitterLayout& splitter : layout.splitters) { + if (splitter.nodeId == nodeId) { + return &splitter; + } + } + + return nullptr; +} + +UIEditorDockHostLayout BuildUIEditorDockHostLayout( + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostState& state, + const UIEditorDockHostMetrics& metrics) { + UIEditorDockHostLayout layout = {}; + layout.bounds = UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + LayoutNodeRecursive( + workspace.root, + layout.bounds, + panelRegistry, + workspace, + session, + state, + metrics, + layout); + return layout; +} + +UIEditorDockHostHitTarget HitTestUIEditorDockHost( + const UIEditorDockHostLayout& layout, + const UIPoint& point) { + for (std::size_t index = layout.splitters.size(); index > 0u; --index) { + const UIEditorDockHostSplitterLayout& splitter = layout.splitters[index - 1u]; + if (IsPointInsideRect(splitter.handleHitRect, point)) { + UIEditorDockHostHitTarget target = {}; + target.kind = UIEditorDockHostHitTargetKind::SplitterHandle; + target.nodeId = splitter.nodeId; + return target; + } + } + + for (std::size_t index = layout.tabStacks.size(); index > 0u; --index) { + const UIEditorDockHostTabStackLayout& tabStack = layout.tabStacks[index - 1u]; + const UIEditorTabStripHitTarget tabHit = + HitTestUIEditorTabStrip(tabStack.tabStripLayout, tabStack.tabStripState, point); + switch (tabHit.kind) { + case UIEditorTabStripHitTargetKind::CloseButton: { + UIEditorDockHostHitTarget target = {}; + target.kind = UIEditorDockHostHitTargetKind::TabCloseButton; + target.nodeId = tabStack.nodeId; + target.index = tabHit.index; + if (tabHit.index < tabStack.items.size()) { + target.panelId = tabStack.items[tabHit.index].panelId; + } + return target; + } + case UIEditorTabStripHitTargetKind::Tab: { + UIEditorDockHostHitTarget target = {}; + target.kind = UIEditorDockHostHitTargetKind::Tab; + target.nodeId = tabStack.nodeId; + target.index = tabHit.index; + if (tabHit.index < tabStack.items.size()) { + target.panelId = tabStack.items[tabHit.index].panelId; + } + return target; + } + case UIEditorTabStripHitTargetKind::HeaderBackground: { + UIEditorDockHostHitTarget target = {}; + target.kind = UIEditorDockHostHitTargetKind::TabStripBackground; + target.nodeId = tabStack.nodeId; + return target; + } + default: + break; + } + + const UIEditorPanelFrameHitTarget panelHit = HitTestUIEditorPanelFrame( + tabStack.contentFrameLayout, + tabStack.contentFrameState, + point); + if (panelHit != UIEditorPanelFrameHitTarget::None) { + return MapPanelFrameHitTarget(panelHit, tabStack.nodeId, tabStack.selectedPanelId); + } + } + + for (std::size_t index = layout.panels.size(); index > 0u; --index) { + const UIEditorDockHostPanelLayout& panel = layout.panels[index - 1u]; + const UIEditorPanelFrameHitTarget hitTarget = HitTestUIEditorPanelFrame( + panel.frameLayout, + panel.frameState, + point); + if (hitTarget != UIEditorPanelFrameHitTarget::None) { + return MapPanelFrameHitTarget(hitTarget, panel.nodeId, panel.panelId); + } + } + + return {}; +} + +void AppendUIEditorDockHostBackground( + UIDrawList& drawList, + const UIEditorDockHostLayout& layout, + const UIEditorDockHostPalette& palette, + const UIEditorDockHostMetrics& metrics) { + for (const UIEditorDockHostPanelLayout& panel : layout.panels) { + AppendUIEditorPanelFrameBackground( + drawList, + panel.frameLayout, + panel.frameState, + palette.panelFramePalette, + metrics.panelFrameMetrics); + } + + const UIEditorPanelFrameMetrics tabContentFrameMetrics = + BuildTabContentFrameMetrics(metrics); + for (const UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { + std::vector tabItems = {}; + tabItems.reserve(tabStack.items.size()); + for (const UIEditorDockHostTabItemLayout& item : tabStack.items) { + UIEditorTabStripItem tabItem = {}; + tabItem.tabId = item.panelId; + tabItem.title = item.title; + tabItem.closable = item.closable; + tabItems.push_back(std::move(tabItem)); + } + + AppendUIEditorTabStripBackground( + drawList, + tabStack.tabStripLayout, + tabStack.tabStripState, + palette.tabStripPalette, + metrics.tabStripMetrics); + AppendUIEditorPanelFrameBackground( + drawList, + tabStack.contentFrameLayout, + tabStack.contentFrameState, + palette.panelFramePalette, + tabContentFrameMetrics); + } + + for (const UIEditorDockHostSplitterLayout& splitter : layout.splitters) { + drawList.AddFilledRect( + splitter.splitterLayout.handleRect, + ResolveSplitterColor(splitter, palette), + metrics.splitterHandleRounding); + } +} + +void AppendUIEditorDockHostForeground( + UIDrawList& drawList, + const UIEditorDockHostLayout& layout, + const UIEditorDockHostPalette& palette, + const UIEditorDockHostMetrics& metrics) { + for (const UIEditorDockHostPanelLayout& panel : layout.panels) { + const UIEditorPanelFrameText text = { + panel.title, + panel.panelId, + panel.active ? "Active Panel" : "Panel Placeholder" + }; + AppendUIEditorPanelFrameForeground( + drawList, + panel.frameLayout, + panel.frameState, + text, + palette.panelFramePalette, + metrics.panelFrameMetrics); + AppendPlaceholderText( + drawList, + panel.frameLayout.bodyRect, + panel.title, + "DockHost standalone panel", + panel.active ? "当前面板为 active" : "点击 header/body 可切换 active", + palette, + metrics); + } + + const UIEditorPanelFrameMetrics tabContentFrameMetrics = + BuildTabContentFrameMetrics(metrics); + for (const UIEditorDockHostTabStackLayout& tabStack : layout.tabStacks) { + std::vector tabItems = {}; + tabItems.reserve(tabStack.items.size()); + for (const UIEditorDockHostTabItemLayout& item : tabStack.items) { + UIEditorTabStripItem tabItem = {}; + tabItem.tabId = item.panelId; + tabItem.title = item.title; + tabItem.closable = item.closable; + tabItems.push_back(std::move(tabItem)); + } + + AppendUIEditorTabStripForeground( + drawList, + tabStack.tabStripLayout, + tabItems, + tabStack.tabStripState, + palette.tabStripPalette, + metrics.tabStripMetrics); + AppendUIEditorPanelFrameForeground( + drawList, + tabStack.contentFrameLayout, + tabStack.contentFrameState, + {}, + palette.panelFramePalette, + tabContentFrameMetrics); + + std::string selectedTitle = "(none)"; + for (const UIEditorDockHostTabItemLayout& item : tabStack.items) { + if (item.panelId == tabStack.selectedPanelId) { + selectedTitle = item.title; + break; + } + } + + AppendPlaceholderText( + drawList, + tabStack.contentFrameLayout.bodyRect, + selectedTitle, + "DockHost tab content placeholder", + "Selected Panel: " + tabStack.selectedPanelId, + palette, + metrics); + } +} + +void AppendUIEditorDockHost( + UIDrawList& drawList, + const UIRect& bounds, + const UIEditorPanelRegistry& panelRegistry, + const UIEditorWorkspaceModel& workspace, + const UIEditorWorkspaceSession& session, + const UIEditorDockHostState& state, + const UIEditorDockHostPalette& palette, + const UIEditorDockHostMetrics& metrics) { + const UIEditorDockHostLayout layout = + BuildUIEditorDockHostLayout(bounds, panelRegistry, workspace, session, state, metrics); + AppendUIEditorDockHostBackground(drawList, layout, palette, metrics); + AppendUIEditorDockHostForeground(drawList, layout, palette, metrics); +} + +} // namespace XCEngine::UI::Editor::Widgets diff --git a/tests/UI/Editor/integration/README.md b/tests/UI/Editor/integration/README.md index c3f5f275..8046ad6f 100644 --- a/tests/UI/Editor/integration/README.md +++ b/tests/UI/Editor/integration/README.md @@ -25,7 +25,7 @@ Scenarios: - `editor.shell.workspace_shell_compose` Build target: `editor_ui_workspace_shell_compose_validation` Executable: `XCUIEditorWorkspaceShellComposeValidation.exe` - Scope: splitters, tab host, panel chrome placeholders, hot reload + Scope: DockHost compose, splitter drag, tab host, panel frame placeholders, workspace active-panel sync - `editor.shell.menu_bar_basic` Build target: `editor_ui_menu_bar_basic_validation` @@ -66,7 +66,7 @@ cmake --build build --config Debug --target editor_ui_integration_tests Selected controls: - `shell/workspace_shell_compose/` - Drag splitters, switch `Document A/B/C`, press `F12`. + Drag splitters, switch `Document A/B/C`, close tabs or side panels, press `Reset`, press `F12`. - `shell/menu_bar_basic/` Click `File / Window / Layout`, move the mouse across menu items, click outside the menu or press `Esc`, press `F12`. diff --git a/tests/UI/Editor/integration/shell/workspace_shell_compose/CMakeLists.txt b/tests/UI/Editor/integration/shell/workspace_shell_compose/CMakeLists.txt index 67df5559..ebe19360 100644 --- a/tests/UI/Editor/integration/shell/workspace_shell_compose/CMakeLists.txt +++ b/tests/UI/Editor/integration/shell/workspace_shell_compose/CMakeLists.txt @@ -1,21 +1,17 @@ -set(EDITOR_UI_WORKSPACE_SHELL_COMPOSE_RESOURCES - View.xcui - ${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/themes/editor_validation.xctheme -) - add_executable(editor_ui_workspace_shell_compose_validation WIN32 main.cpp - ${EDITOR_UI_WORKSPACE_SHELL_COMPOSE_RESOURCES} ) target_include_directories(editor_ui_workspace_shell_compose_validation PRIVATE - ${CMAKE_SOURCE_DIR}/tests/UI/Editor/integration/shared/src + ${CMAKE_SOURCE_DIR}/new_editor/include + ${CMAKE_SOURCE_DIR}/new_editor/app ${CMAKE_SOURCE_DIR}/engine/include ) target_compile_definitions(editor_ui_workspace_shell_compose_validation PRIVATE UNICODE _UNICODE + XCENGINE_EDITOR_UI_TESTS_REPO_ROOT="${XCENGINE_EDITOR_UI_TESTS_REPO_ROOT_PATH}" ) if(MSVC) @@ -25,11 +21,10 @@ if(MSVC) endif() target_link_libraries(editor_ui_workspace_shell_compose_validation PRIVATE - editor_ui_integration_host + XCUIEditorLib + XCUIEditorHost ) set_target_properties(editor_ui_workspace_shell_compose_validation PROPERTIES OUTPUT_NAME "XCUIEditorWorkspaceShellComposeValidation" ) - -source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" FILES View.xcui) diff --git a/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp b/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp index d185e5ba..1fe19fa6 100644 --- a/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp +++ b/tests/UI/Editor/integration/shell/workspace_shell_compose/main.cpp @@ -1,8 +1,806 @@ -#include "Application.h" +#ifndef NOMINMAX +#define NOMINMAX +#endif + +#include +#include +#include +#include +#include "Host/AutoScreenshot.h" +#include "Host/NativeRenderer.h" + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#ifndef XCENGINE_EDITOR_UI_TESTS_REPO_ROOT +#define XCENGINE_EDITOR_UI_TESTS_REPO_ROOT "." +#endif + +namespace { + +using XCEngine::UI::UIColor; +using XCEngine::UI::UIDrawData; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Widgets::BeginUISplitterDrag; +using XCEngine::UI::Widgets::EndUISplitterDrag; +using XCEngine::UI::Widgets::UISplitterDragState; +using XCEngine::UI::Widgets::UpdateUISplitterDrag; +using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceController; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::FindUIEditorWorkspaceNode; +using XCEngine::UI::Editor::GetUIEditorWorkspaceCommandStatusName; +using XCEngine::UI::Editor::GetUIEditorWorkspaceLayoutOperationStatusName; +using XCEngine::UI::Editor::Host::AutoScreenshotController; +using XCEngine::UI::Editor::Host::NativeRenderer; +using XCEngine::UI::Editor::TrySetUIEditorWorkspaceSplitRatio; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorWorkspaceCommand; +using XCEngine::UI::Editor::UIEditorWorkspaceCommandKind; +using XCEngine::UI::Editor::UIEditorWorkspaceCommandResult; +using XCEngine::UI::Editor::UIEditorWorkspaceController; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceNode; +using XCEngine::UI::Editor::UIEditorWorkspaceNodeKind; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorDockHostLayout; +using XCEngine::UI::Editor::Widgets::FindUIEditorDockHostSplitterLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorDockHost; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostLayout; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostState; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex; + +constexpr const wchar_t* kWindowClassName = L"XCUIEditorWorkspaceShellComposeValidation"; +constexpr const wchar_t* kWindowTitle = L"XCUI Editor | Workspace Shell Compose"; + +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.30f, 0.30f, 0.30f, 1.0f); +constexpr UIColor kTextPrimary(0.94f, 0.94f, 0.94f, 1.0f); +constexpr UIColor kTextMuted(0.73f, 0.73f, 0.73f, 1.0f); +constexpr UIColor kTextWeak(0.58f, 0.58f, 0.58f, 1.0f); +constexpr UIColor kSuccess(0.63f, 0.76f, 0.63f, 1.0f); +constexpr UIColor kDanger(0.82f, 0.50f, 0.50f, 1.0f); +constexpr UIColor kButtonBg(0.26f, 0.26f, 0.26f, 1.0f); +constexpr UIColor kButtonHoveredBg(0.34f, 0.34f, 0.34f, 1.0f); + +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(); +} + +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; +} + +bool AreTargetsEqual( + const UIEditorDockHostHitTarget& lhs, + const UIEditorDockHostHitTarget& rhs) { + return lhs.kind == rhs.kind && + lhs.nodeId == rhs.nodeId && + lhs.panelId == rhs.panelId && + lhs.index == rhs.index; +} + +void DrawCard( + UIDrawList& drawList, + const UIRect& rect, + std::string_view title, + std::string_view subtitle = {}) { + drawList.AddFilledRect(rect, kCardBg, 10.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 10.0f); + drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 14.0f), std::string(title), kTextPrimary, 17.0f); + if (!subtitle.empty()) { + drawList.AddText(UIPoint(rect.x + 16.0f, rect.y + 40.0f), std::string(subtitle), kTextMuted, 12.0f); + } +} + +void DrawButton( + UIDrawList& drawList, + const UIRect& rect, + std::string_view label, + bool hovered) { + drawList.AddFilledRect(rect, hovered ? kButtonHoveredBg : kButtonBg, 8.0f); + drawList.AddRectOutline(rect, kCardBorder, 1.0f, 8.0f); + drawList.AddText(UIPoint(rect.x + 14.0f, rect.y + 11.0f), std::string(label), kTextPrimary, 13.0f); +} + +UIEditorPanelRegistry BuildPanelRegistry() { + UIEditorPanelRegistry registry = {}; + registry.panels = { + { "hierarchy", "Hierarchy", {}, true, true, false }, + { "doc-a", "Document A", {}, true, true, true }, + { "doc-b", "Document B", {}, true, true, true }, + { "doc-c", "Document C", {}, true, true, false }, + { "inspector", "Inspector", {}, true, true, true }, + { "console", "Console", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-top-bottom", + UIEditorWorkspaceSplitAxis::Vertical, + 0.76f, + BuildUIEditorWorkspaceSplit( + "top-left-right", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.22f, + BuildUIEditorWorkspacePanel("hierarchy-node", "hierarchy", "Hierarchy", true), + BuildUIEditorWorkspaceSplit( + "center-right-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.72f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true), + BuildUIEditorWorkspacePanel("doc-c-node", "doc-c", "Document C", true) + }, + 0u), + BuildUIEditorWorkspacePanel("inspector-node", "inspector", "Inspector", true))), + BuildUIEditorWorkspacePanel("console-node", "console", "Console", true)); + workspace.activePanelId = "doc-a"; + return workspace; +} + +std::string DescribeHitTarget(const UIEditorDockHostHitTarget& target) { + switch (target.kind) { + case UIEditorDockHostHitTargetKind::SplitterHandle: + return "Splitter: " + target.nodeId; + case UIEditorDockHostHitTargetKind::TabStripBackground: + return "TabStripBackground: " + target.nodeId; + case UIEditorDockHostHitTargetKind::Tab: + return "Tab: " + target.panelId; + case UIEditorDockHostHitTargetKind::TabCloseButton: + return "TabClose: " + target.panelId; + case UIEditorDockHostHitTargetKind::PanelHeader: + return "PanelHeader: " + target.panelId; + case UIEditorDockHostHitTargetKind::PanelBody: + return "PanelBody: " + target.panelId; + case UIEditorDockHostHitTargetKind::PanelFooter: + return "PanelFooter: " + target.panelId; + case UIEditorDockHostHitTargetKind::PanelCloseButton: + return "PanelClose: " + target.panelId; + case UIEditorDockHostHitTargetKind::None: + default: + return "None"; + } +} + +std::string JoinVisiblePanelIds(const UIEditorWorkspaceController& controller) { + const auto panels = XCEngine::UI::Editor::CollectUIEditorWorkspaceVisiblePanels( + controller.GetWorkspace(), + controller.GetSession()); + 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 FormatSplitRatio( + const UIEditorWorkspaceController& controller, + std::string_view nodeId) { + const UIEditorWorkspaceNode* node = + FindUIEditorWorkspaceNode(controller.GetWorkspace(), nodeId); + if (node == nullptr || node->kind != UIEditorWorkspaceNodeKind::Split) { + return std::string(nodeId) + ": n/a"; + } + + std::ostringstream stream; + stream.setf(std::ios::fixed, std::ios::floatfield); + stream.precision(2); + stream << nodeId << ": " << node->splitRatio; + return stream.str(); +} + +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(message.wParam); + } + +private: + static LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam) { + if (message == WM_NCCREATE) { + const auto* createStruct = reinterpret_cast(lParam); + auto* app = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(app)); + return TRUE; + } + + auto* app = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + switch (message) { + case WM_SIZE: + if (app != nullptr && wParam != SIZE_MINIMIZED) { + app->OnResize(static_cast(LOWORD(lParam)), static_cast(HIWORD(lParam))); + } + return 0; + case WM_MOUSEMOVE: + if (app != nullptr) { + app->HandleMouseMove( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_MOUSELEAVE: + if (app != nullptr) { + app->HandleMouseLeave(); + return 0; + } + break; + case WM_LBUTTONDOWN: + if (app != nullptr) { + app->HandleLeftButtonDown( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_LBUTTONUP: + if (app != nullptr) { + app->HandleLeftButtonUp( + static_cast(GET_X_LPARAM(lParam)), + static_cast(GET_Y_LPARAM(lParam))); + return 0; + } + break; + case WM_KEYDOWN: + case WM_SYSKEYDOWN: + if (app != nullptr && wParam == VK_F12) { + app->m_autoScreenshot.RequestCapture("manual_f12"); + return 0; + } + break; + case WM_PAINT: + if (app != nullptr) { + PAINTSTRUCT paintStruct = {}; + BeginPaint(hwnd, &paintStruct); + app->RenderFrame(); + EndPaint(hwnd, &paintStruct); + 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_captureRoot = + ResolveRepoRootPath() / "tests/UI/Editor/integration/shell/workspace_shell_compose/captures"; + m_autoScreenshot.Initialize(m_captureRoot); + + 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, + 1480, + 940, + 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; + } + + ResetScenario(); + 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) { + UnregisterClassW(kWindowClassName, GetModuleHandleW(nullptr)); + m_windowClassAtom = 0; + } + } + + void ResetScenario() { + m_controller = + BuildDefaultUIEditorWorkspaceController(BuildPanelRegistry(), BuildWorkspace()); + m_dockState = {}; + m_layout = {}; + m_dragState = {}; + m_dragSplitterNodeId.clear(); + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_resetHovered = false; + m_lastResult = "等待操作"; + UpdateSceneRects(); + RefreshLayout(); + UpdateHoverTarget(); + } + + void OnResize(UINT width, UINT height) { + if (width == 0u || height == 0u) { + return; + } + + m_renderer.Resize(width, height); + UpdateSceneRects(); + } + + void UpdateSceneRects() { + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(1L, clientRect.right - clientRect.left)); + const float height = static_cast((std::max)(1L, clientRect.bottom - clientRect.top)); + + constexpr float outerPadding = 20.0f; + constexpr float leftColumnWidth = 380.0f; + m_introRect = UIRect(outerPadding, outerPadding, leftColumnWidth, 192.0f); + m_stateRect = UIRect(outerPadding, 228.0f, leftColumnWidth, height - 248.0f); + m_previewCardRect = UIRect( + leftColumnWidth + outerPadding * 2.0f, + outerPadding, + width - leftColumnWidth - outerPadding * 3.0f, + height - outerPadding * 2.0f); + m_previewRect = UIRect( + m_previewCardRect.x + 20.0f, + m_previewCardRect.y + 20.0f, + m_previewCardRect.width - 40.0f, + m_previewCardRect.height - 40.0f); + m_resetButtonRect = UIRect( + m_stateRect.x + 16.0f, + m_stateRect.y + 220.0f, + m_stateRect.width - 32.0f, + 38.0f); + } + + void RefreshLayout() { + m_layout = BuildUIEditorDockHostLayout( + m_previewRect, + m_controller.GetPanelRegistry(), + m_controller.GetWorkspace(), + m_controller.GetSession(), + m_dockState); + } + + void UpdateHoverTarget() { + UIEditorDockHostHitTarget hoveredTarget = HitTestUIEditorDockHost(m_layout, m_mousePosition); + if (!AreTargetsEqual(hoveredTarget, m_dockState.hoveredTarget)) { + m_dockState.hoveredTarget = std::move(hoveredTarget); + RefreshLayout(); + } + } + + void HandleMouseMove(float x, float y) { + m_mousePosition = UIPoint(x, y); + TRACKMOUSEEVENT event = {}; + event.cbSize = sizeof(event); + event.dwFlags = TME_LEAVE; + event.hwndTrack = m_hwnd; + TrackMouseEvent(&event); + + UpdateSceneRects(); + m_resetHovered = ContainsPoint(m_resetButtonRect, x, y); + + if (m_dragState.active) { + XCEngine::UI::Layout::UISplitterLayoutResult draggedLayout = {}; + if (UpdateUISplitterDrag(m_dragState, m_mousePosition, draggedLayout)) { + ApplyDraggedSplitterRatio(draggedLayout.splitRatio); + } else { + RefreshLayout(); + } + m_dockState.hoveredTarget = { + UIEditorDockHostHitTargetKind::SplitterHandle, + m_dragSplitterNodeId, + {}, + UIEditorTabStripInvalidIndex + }; + m_dockState.activeSplitterNodeId = m_dragSplitterNodeId; + RefreshLayout(); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + RefreshLayout(); + UpdateHoverTarget(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleMouseLeave() { + if (m_dragState.active) { + return; + } + + m_mousePosition = UIPoint(-1000.0f, -1000.0f); + m_resetHovered = false; + m_dockState.hoveredTarget = {}; + RefreshLayout(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleLeftButtonDown(float x, float y) { + UpdateSceneRects(); + m_mousePosition = UIPoint(x, y); + m_dockState.focused = true; + RefreshLayout(); + UpdateHoverTarget(); + + const UIEditorDockHostHitTarget hit = m_dockState.hoveredTarget; + if (hit.kind != UIEditorDockHostHitTargetKind::SplitterHandle) { + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + const auto* splitter = FindUIEditorDockHostSplitterLayout(m_layout, hit.nodeId); + if (splitter == nullptr) { + return; + } + + if (!BeginUISplitterDrag( + 1u, + splitter->axis == UIEditorWorkspaceSplitAxis::Horizontal + ? XCEngine::UI::Layout::UILayoutAxis::Horizontal + : XCEngine::UI::Layout::UILayoutAxis::Vertical, + splitter->bounds, + splitter->splitterLayout, + splitter->constraints, + splitter->metrics, + m_mousePosition, + m_dragState)) { + return; + } + + m_dragSplitterNodeId = splitter->nodeId; + m_dockState.activeSplitterNodeId = splitter->nodeId; + SetCapture(m_hwnd); + m_lastResult = "开始拖拽 splitter: " + splitter->nodeId; + RefreshLayout(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void HandleLeftButtonUp(float x, float y) { + UpdateSceneRects(); + m_mousePosition = UIPoint(x, y); + + if (m_dragState.active) { + XCEngine::UI::Layout::UISplitterLayoutResult draggedLayout = {}; + if (UpdateUISplitterDrag(m_dragState, m_mousePosition, draggedLayout)) { + ApplyDraggedSplitterRatio(draggedLayout.splitRatio); + } + + EndUISplitterDrag(m_dragState); + m_dockState.activeSplitterNodeId.clear(); + if (GetCapture() == m_hwnd) { + ReleaseCapture(); + } + m_lastResult = "结束拖拽 splitter: " + m_dragSplitterNodeId; + m_dragSplitterNodeId.clear(); + RefreshLayout(); + UpdateHoverTarget(); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + if (ContainsPoint(m_resetButtonRect, x, y)) { + ResetScenario(); + InvalidateRect(m_hwnd, nullptr, FALSE); + return; + } + + RefreshLayout(); + UpdateHoverTarget(); + ExecuteClick(m_dockState.hoveredTarget); + RefreshLayout(); + UpdateHoverTarget(); + InvalidateRect(m_hwnd, nullptr, FALSE); + } + + void ExecuteClick(const UIEditorDockHostHitTarget& hit) { + switch (hit.kind) { + case UIEditorDockHostHitTargetKind::Tab: + DispatchWorkspaceCommand( + UIEditorWorkspaceCommandKind::ActivatePanel, + hit.panelId, + "Activate Tab"); + return; + case UIEditorDockHostHitTargetKind::TabCloseButton: + DispatchWorkspaceCommand( + UIEditorWorkspaceCommandKind::ClosePanel, + hit.panelId, + "Close Tab"); + return; + case UIEditorDockHostHitTargetKind::PanelHeader: + case UIEditorDockHostHitTargetKind::PanelBody: + case UIEditorDockHostHitTargetKind::PanelFooter: + DispatchWorkspaceCommand( + UIEditorWorkspaceCommandKind::ActivatePanel, + hit.panelId, + "Activate Panel"); + return; + case UIEditorDockHostHitTargetKind::PanelCloseButton: + DispatchWorkspaceCommand( + UIEditorWorkspaceCommandKind::ClosePanel, + hit.panelId, + "Close Panel"); + return; + case UIEditorDockHostHitTargetKind::TabStripBackground: + m_lastResult = "DockHost focus = On"; + return; + case UIEditorDockHostHitTargetKind::None: + default: + m_dockState.focused = false; + m_lastResult = "DockHost focus = Off"; + return; + } + } + + void DispatchWorkspaceCommand( + UIEditorWorkspaceCommandKind kind, + std::string_view panelId, + std::string_view label) { + UIEditorWorkspaceCommand command = {}; + command.kind = kind; + command.panelId = std::string(panelId); + + const UIEditorWorkspaceCommandResult result = m_controller.Dispatch(command); + m_lastResult = + std::string(label) + " -> " + + std::string(GetUIEditorWorkspaceCommandStatusName(result.status)) + + " | " + + result.message; + } + + void ApplyDraggedSplitterRatio(float splitRatio) { + auto snapshot = m_controller.CaptureLayoutSnapshot(); + if (!TrySetUIEditorWorkspaceSplitRatio(snapshot.workspace, m_dragSplitterNodeId, splitRatio)) { + return; + } + + const auto result = m_controller.RestoreLayoutSnapshot(snapshot); + std::ostringstream stream; + stream.setf(std::ios::fixed, std::ios::floatfield); + stream.precision(2); + stream << "Drag " << m_dragSplitterNodeId << " -> " + << GetUIEditorWorkspaceLayoutOperationStatusName(result.status) + << " | ratio=" << splitRatio; + m_lastResult = stream.str(); + RefreshLayout(); + } + + void RenderFrame() { + UpdateSceneRects(); + RefreshLayout(); + UpdateHoverTarget(); + + RECT clientRect = {}; + GetClientRect(m_hwnd, &clientRect); + const float width = static_cast((std::max)(1L, clientRect.right - clientRect.left)); + const float height = static_cast((std::max)(1L, clientRect.bottom - clientRect.top)); + + UIDrawData drawData = {}; + UIDrawList& drawList = drawData.EmplaceDrawList("EditorWorkspaceShellCompose"); + drawList.AddFilledRect(UIRect(0.0f, 0.0f, width, height), kWindowBg); + + DrawCard( + drawList, + m_introRect, + "测试功能:DockHost / Workspace Compose 基础层", + "只验证 DockHost 真实 compose、splitter drag、tab host、panel frame 联动,不包含业务面板。"); + drawList.AddText( + UIPoint(m_introRect.x + 16.0f, m_introRect.y + 68.0f), + "重点检查:三条 splitter 的 resize 是否稳定;关闭面板后 branch 是否正确 collapse;点击 tab / panel 是否同步 active。", + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(m_introRect.x + 16.0f, m_introRect.y + 92.0f), + "操作:拖拽 Hierarchy / Documents / Inspector / Console 之间的 splitter;点击 Document A/B/C;点 X 关闭 tab 或 side panel;Reset 恢复;F12 截图。", + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(m_introRect.x + 16.0f, m_introRect.y + 116.0f), + "预期:splitter 会被最小尺寸 clamp;Document C 没有 X;Inspector/Console 关闭后对应分支会收拢,不应留下歪掉的空洞。", + kTextWeak, + 12.0f); + + DrawCard( + drawList, + m_stateRect, + "状态回显", + "这里直接回显 hover / focus / dragging / active panel / split ratio,方便人工检查。"); + DrawButton(drawList, m_resetButtonRect, "Reset", m_resetHovered); + + const auto validation = m_controller.ValidateState(); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 70.0f), + "Hover: " + DescribeHitTarget(m_dockState.hoveredTarget), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 96.0f), + std::string("Focused: ") + (m_dockState.focused ? "On" : "Off"), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 122.0f), + "Dragging: " + (m_dragSplitterNodeId.empty() ? std::string("(none)") : m_dragSplitterNodeId), + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 148.0f), + "Active Panel: " + m_controller.GetWorkspace().activePanelId, + kTextPrimary, + 13.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 174.0f), + "Visible Panels: " + JoinVisiblePanelIds(m_controller), + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 280.0f), + FormatSplitRatio(m_controller, "root-top-bottom"), + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 302.0f), + FormatSplitRatio(m_controller, "top-left-right"), + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 324.0f), + FormatSplitRatio(m_controller, "center-right-split"), + kTextPrimary, + 12.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 356.0f), + "Result: " + m_lastResult, + kTextMuted, + 12.0f); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 382.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/shell/workspace_shell_compose/captures/") + : m_autoScreenshot.GetLastCaptureSummary()); + drawList.AddText( + UIPoint(m_stateRect.x + 16.0f, m_stateRect.y + 408.0f), + captureSummary, + kTextWeak, + 12.0f); + + DrawCard( + drawList, + m_previewCardRect, + "预览区", + "这里只保留一个 DockHost 试验场,不混入业务 UI。"); + + AppendUIEditorDockHostBackground(drawList, m_layout); + AppendUIEditorDockHostForeground(drawList, m_layout); + + const bool framePresented = m_renderer.Render(drawData); + m_autoScreenshot.CaptureIfRequested( + m_renderer, + drawData, + static_cast(width), + static_cast(height), + framePresented); + } + + HWND m_hwnd = nullptr; + ATOM m_windowClassAtom = 0; + NativeRenderer m_renderer = {}; + AutoScreenshotController m_autoScreenshot = {}; + std::filesystem::path m_captureRoot = {}; + UIEditorWorkspaceController m_controller = {}; + UIEditorDockHostState m_dockState = {}; + UIEditorDockHostLayout m_layout = {}; + UISplitterDragState m_dragState = {}; + std::string m_dragSplitterNodeId = {}; + UIPoint m_mousePosition = UIPoint(-1000.0f, -1000.0f); + UIRect m_introRect = {}; + UIRect m_stateRect = {}; + UIRect m_previewCardRect = {}; + UIRect m_previewRect = {}; + UIRect m_resetButtonRect = {}; + bool m_resetHovered = false; + std::string m_lastResult = {}; +}; + +} // namespace int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE, LPWSTR, int nCmdShow) { - return XCEngine::Tests::EditorUI::RunEditorUIValidationApp( - hInstance, - nCmdShow, - "editor.shell.workspace_shell_compose"); + return ScenarioApp().Run(hInstance, nCmdShow); } diff --git a/tests/UI/Editor/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index bee43566..5d4504b7 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -8,6 +8,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_ui_editor_menu_session.cpp test_ui_editor_panel_registry.cpp test_ui_editor_collection_primitives.cpp + test_ui_editor_dock_host.cpp test_ui_editor_panel_chrome.cpp test_ui_editor_panel_frame.cpp test_ui_editor_tab_strip.cpp diff --git a/tests/UI/Editor/unit/test_ui_editor_dock_host.cpp b/tests/UI/Editor/unit/test_ui_editor_dock_host.cpp new file mode 100644 index 00000000..79079e76 --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_dock_host.cpp @@ -0,0 +1,209 @@ +#include + +#include +#include +#include +#include +#include + +namespace { + +using XCEngine::UI::UIDrawCommandType; +using XCEngine::UI::UIDrawList; +using XCEngine::UI::UIPoint; +using XCEngine::UI::UIRect; +using XCEngine::UI::Editor::BuildDefaultUIEditorWorkspaceSession; +using XCEngine::UI::Editor::BuildUIEditorWorkspacePanel; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceSplit; +using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; +using XCEngine::UI::Editor::TryHideUIEditorWorkspacePanel; +using XCEngine::UI::Editor::UIEditorPanelRegistry; +using XCEngine::UI::Editor::UIEditorWorkspaceModel; +using XCEngine::UI::Editor::UIEditorWorkspaceSession; +using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; +using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostBackground; +using XCEngine::UI::Editor::Widgets::AppendUIEditorDockHostForeground; +using XCEngine::UI::Editor::Widgets::BuildUIEditorDockHostLayout; +using XCEngine::UI::Editor::Widgets::FindUIEditorDockHostSplitterLayout; +using XCEngine::UI::Editor::Widgets::HitTestUIEditorDockHost; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTarget; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostHitTargetKind; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostLayout; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostMetrics; +using XCEngine::UI::Editor::Widgets::UIEditorDockHostState; +using XCEngine::UI::Editor::Widgets::UIEditorTabStripInvalidIndex; + +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 }, + { "console", "Console", {}, true, true, true } + }; + return registry; +} + +UIEditorWorkspaceModel BuildWorkspace() { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.5f, + BuildUIEditorWorkspaceTabStack( + "document-tabs", + { + BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true), + BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true) + }, + 1u), + BuildUIEditorWorkspaceSplit( + "right-split", + UIEditorWorkspaceSplitAxis::Vertical, + 0.6f, + BuildUIEditorWorkspacePanel("details-node", "details", "Details", true), + BuildUIEditorWorkspacePanel("console-node", "console", "Console", true))); + workspace.activePanelId = "doc-b"; + return workspace; +} + +} // namespace + +TEST(UIEditorDockHostTest, LayoutComposesSplitTabStackAndStandalonePanelsFromWorkspaceTree) { + const UIEditorPanelRegistry registry = BuildPanelRegistry(); + const UIEditorWorkspaceModel workspace = BuildWorkspace(); + const UIEditorWorkspaceSession session = + BuildDefaultUIEditorWorkspaceSession(registry, workspace); + + const UIEditorDockHostLayout layout = BuildUIEditorDockHostLayout( + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + registry, + workspace, + session); + + ASSERT_EQ(layout.splitters.size(), 2u); + ASSERT_EQ(layout.tabStacks.size(), 1u); + ASSERT_EQ(layout.panels.size(), 2u); + + const auto* rootSplitter = FindUIEditorDockHostSplitterLayout(layout, "root-split"); + ASSERT_NE(rootSplitter, nullptr); + EXPECT_FLOAT_EQ(rootSplitter->splitterLayout.handleRect.x, 395.0f); + EXPECT_FLOAT_EQ(rootSplitter->splitterLayout.handleRect.width, 10.0f); + + const auto& tabStack = layout.tabStacks.front(); + EXPECT_EQ(tabStack.nodeId, "document-tabs"); + EXPECT_EQ(tabStack.selectedPanelId, "doc-b"); + ASSERT_EQ(tabStack.items.size(), 2u); + EXPECT_EQ(tabStack.items[0].panelId, "doc-a"); + EXPECT_EQ(tabStack.items[1].panelId, "doc-b"); + EXPECT_EQ(tabStack.tabStripState.selectedIndex, 1u); + + EXPECT_EQ(layout.panels[0].panelId, "details"); + EXPECT_EQ(layout.panels[1].panelId, "console"); +} + +TEST(UIEditorDockHostTest, HiddenBranchCollapsesAndVisibleBranchUsesFullBounds) { + const UIEditorPanelRegistry registry = BuildPanelRegistry(); + UIEditorWorkspaceModel workspace = BuildWorkspace(); + UIEditorWorkspaceSession session = + BuildDefaultUIEditorWorkspaceSession(registry, workspace); + + ASSERT_TRUE(TryHideUIEditorWorkspacePanel(registry, workspace, session, "details")); + ASSERT_TRUE(TryHideUIEditorWorkspacePanel(registry, workspace, session, "console")); + + const UIEditorDockHostLayout layout = BuildUIEditorDockHostLayout( + UIRect(10.0f, 20.0f, 640.0f, 480.0f), + registry, + workspace, + session); + + EXPECT_TRUE(layout.splitters.empty()); + ASSERT_EQ(layout.tabStacks.size(), 1u); + EXPECT_TRUE(layout.panels.empty()); + EXPECT_FLOAT_EQ(layout.tabStacks.front().bounds.x, 10.0f); + EXPECT_FLOAT_EQ(layout.tabStacks.front().bounds.y, 20.0f); + EXPECT_FLOAT_EQ(layout.tabStacks.front().bounds.width, 640.0f); + EXPECT_FLOAT_EQ(layout.tabStacks.front().bounds.height, 480.0f); +} + +TEST(UIEditorDockHostTest, HitTestPrioritizesSplitterThenTabCloseThenPanelBody) { + const UIEditorPanelRegistry registry = BuildPanelRegistry(); + const UIEditorWorkspaceModel workspace = BuildWorkspace(); + const UIEditorWorkspaceSession session = + BuildDefaultUIEditorWorkspaceSession(registry, workspace); + + UIEditorDockHostState state = {}; + state.focused = true; + const UIEditorDockHostLayout layout = BuildUIEditorDockHostLayout( + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + registry, + workspace, + session, + state); + + const auto splitterHit = HitTestUIEditorDockHost( + layout, + UIPoint(396.0f, 120.0f)); + EXPECT_EQ(splitterHit.kind, UIEditorDockHostHitTargetKind::SplitterHandle); + EXPECT_EQ(splitterHit.nodeId, "root-split"); + + ASSERT_EQ(layout.tabStacks.size(), 1u); + const auto& closeRect = layout.tabStacks.front().tabStripLayout.closeButtonRects[1]; + const auto tabCloseHit = HitTestUIEditorDockHost( + layout, + UIPoint(closeRect.x + closeRect.width * 0.5f, closeRect.y + closeRect.height * 0.5f)); + EXPECT_EQ(tabCloseHit.kind, UIEditorDockHostHitTargetKind::TabCloseButton); + EXPECT_EQ(tabCloseHit.nodeId, "document-tabs"); + EXPECT_EQ(tabCloseHit.panelId, "doc-b"); + EXPECT_EQ(tabCloseHit.index, 1u); + + const auto panelBodyHit = HitTestUIEditorDockHost( + layout, + UIPoint(40.0f, 90.0f)); + EXPECT_EQ(panelBodyHit.kind, UIEditorDockHostHitTargetKind::PanelBody); + EXPECT_EQ(panelBodyHit.nodeId, "document-tabs"); + EXPECT_EQ(panelBodyHit.panelId, "doc-b"); +} + +TEST(UIEditorDockHostTest, BackgroundAndForegroundEmitStableCompositeCommands) { + const UIEditorPanelRegistry registry = BuildPanelRegistry(); + const UIEditorWorkspaceModel workspace = BuildWorkspace(); + const UIEditorWorkspaceSession session = + BuildDefaultUIEditorWorkspaceSession(registry, workspace); + + UIEditorDockHostState state = {}; + state.focused = true; + state.hoveredTarget = UIEditorDockHostHitTarget{ + UIEditorDockHostHitTargetKind::TabCloseButton, + "document-tabs", + "doc-b", + 1u + }; + state.activeSplitterNodeId = "root-split"; + + const UIEditorDockHostLayout layout = BuildUIEditorDockHostLayout( + UIRect(0.0f, 0.0f, 800.0f, 600.0f), + registry, + workspace, + session, + state); + + UIDrawList background("DockHostBackground"); + AppendUIEditorDockHostBackground(background, layout); + EXPECT_GT(background.GetCommandCount(), 10u); + EXPECT_EQ(background.GetCommands().front().type, UIDrawCommandType::FilledRect); + + UIDrawList foreground("DockHostForeground"); + AppendUIEditorDockHostForeground(foreground, layout); + EXPECT_GT(foreground.GetCommandCount(), 10u); + + bool foundPlaceholderText = false; + for (const auto& command : foreground.GetCommands()) { + if (command.type == UIDrawCommandType::Text && + command.text == "DockHost tab content placeholder") { + foundPlaceholderText = true; + break; + } + } + EXPECT_TRUE(foundPlaceholderText); +} diff --git a/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp b/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp index a38b4918..23180245 100644 --- a/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp +++ b/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp @@ -14,7 +14,9 @@ using XCEngine::UI::Editor::BuildUIEditorWorkspaceTabStack; using XCEngine::UI::Editor::CollectUIEditorWorkspaceVisiblePanels; using XCEngine::UI::Editor::ContainsUIEditorWorkspacePanel; using XCEngine::UI::Editor::FindUIEditorWorkspaceActivePanel; +using XCEngine::UI::Editor::FindUIEditorWorkspaceNode; using XCEngine::UI::Editor::TryActivateUIEditorWorkspacePanel; +using XCEngine::UI::Editor::TrySetUIEditorWorkspaceSplitRatio; using XCEngine::UI::Editor::UIEditorWorkspaceModel; using XCEngine::UI::Editor::UIEditorWorkspaceNodeKind; using XCEngine::UI::Editor::UIEditorWorkspaceSplitAxis; @@ -141,3 +143,23 @@ TEST(UIEditorWorkspaceModelTest, ValidationRejectsActivePanelHiddenByCurrentTabS const auto result = ValidateUIEditorWorkspace(workspace); EXPECT_EQ(result.code, UIEditorWorkspaceValidationCode::InvalidActivePanelId); } + +TEST(UIEditorWorkspaceModelTest, SplitRatioMutationTargetsSplitNodeAndRejectsInvalidValues) { + UIEditorWorkspaceModel workspace = {}; + workspace.root = BuildUIEditorWorkspaceSplit( + "root-split", + UIEditorWorkspaceSplitAxis::Horizontal, + 0.62f, + BuildUIEditorWorkspacePanel("left-node", "left", "Left", true), + BuildUIEditorWorkspacePanel("right-node", "right", "Right", true)); + + ASSERT_TRUE(TrySetUIEditorWorkspaceSplitRatio(workspace, "root-split", 0.35f)); + const auto* splitNode = FindUIEditorWorkspaceNode(workspace, "root-split"); + ASSERT_NE(splitNode, nullptr); + EXPECT_FLOAT_EQ(splitNode->splitRatio, 0.35f); + + EXPECT_FALSE(TrySetUIEditorWorkspaceSplitRatio(workspace, "root-split", 0.35f)); + EXPECT_FALSE(TrySetUIEditorWorkspaceSplitRatio(workspace, "left-node", 0.5f)); + EXPECT_FALSE(TrySetUIEditorWorkspaceSplitRatio(workspace, "missing", 0.5f)); + EXPECT_FALSE(TrySetUIEditorWorkspaceSplitRatio(workspace, "root-split", 1.0f)); +}