feat(xcui): add tab strip and workspace compose foundations

This commit is contained in:
2026-04-06 04:27:54 +08:00
parent 3540dbc94d
commit b14a4fb7bb
27 changed files with 2075 additions and 41 deletions

View File

@@ -0,0 +1,133 @@
#pragma once
#include <XCEngine/UI/Layout/LayoutTypes.h>
#include <algorithm>
#include <cstddef>
#include <vector>
namespace XCEngine {
namespace UI {
namespace Layout {
struct UITabStripMetrics {
float headerHeight = 34.0f;
float tabMinWidth = 72.0f;
float tabHorizontalPadding = 12.0f;
float tabGap = 1.0f;
};
struct UITabStripMeasureItem {
float desiredHeaderLabelWidth = 0.0f;
UISize desiredContentSize = {};
UISize minimumContentSize = {};
};
struct UITabStripMeasureResult {
UISize desiredSize = {};
UISize minimumSize = {};
};
struct UITabStripLayoutResult {
UIRect headerRect = {};
UIRect contentRect = {};
std::vector<UIRect> tabHeaderRects = {};
};
inline float MeasureUITabStripHeaderWidth(
float labelWidth,
const UITabStripMetrics& metrics) {
return (std::max)(
metrics.tabMinWidth,
(std::max)(0.0f, labelWidth) + (std::max)(0.0f, metrics.tabHorizontalPadding) * 2.0f);
}
inline UITabStripMeasureResult MeasureUITabStrip(
const std::vector<UITabStripMeasureItem>& items,
const UITabStripMetrics& metrics) {
UITabStripMeasureResult result = {};
const float gap = (std::max)(0.0f, metrics.tabGap);
float desiredHeaderWidth = 0.0f;
float minimumHeaderWidth = 0.0f;
float desiredContentWidth = 0.0f;
float desiredContentHeight = 0.0f;
float minimumContentWidth = 0.0f;
float minimumContentHeight = 0.0f;
for (std::size_t index = 0; index < items.size(); ++index) {
const UITabStripMeasureItem& item = items[index];
desiredHeaderWidth += MeasureUITabStripHeaderWidth(
item.desiredHeaderLabelWidth,
metrics);
minimumHeaderWidth += metrics.tabMinWidth;
if (index > 0u) {
desiredHeaderWidth += gap;
minimumHeaderWidth += gap;
}
desiredContentWidth = (std::max)(desiredContentWidth, item.desiredContentSize.width);
desiredContentHeight = (std::max)(desiredContentHeight, item.desiredContentSize.height);
minimumContentWidth = (std::max)(minimumContentWidth, item.minimumContentSize.width);
minimumContentHeight = (std::max)(minimumContentHeight, item.minimumContentSize.height);
}
const float headerHeight = items.empty() ? 0.0f : (std::max)(0.0f, metrics.headerHeight);
result.desiredSize = UISize(
(std::max)(desiredHeaderWidth, desiredContentWidth),
headerHeight + desiredContentHeight);
result.minimumSize = UISize(
(std::max)(minimumHeaderWidth, minimumContentWidth),
headerHeight + minimumContentHeight);
return result;
}
inline UITabStripLayoutResult ArrangeUITabStrip(
const UIRect& bounds,
const std::vector<float>& desiredHeaderWidths,
const UITabStripMetrics& metrics) {
UITabStripLayoutResult result = {};
const float headerHeight = desiredHeaderWidths.empty()
? 0.0f
: (std::min)((std::max)(0.0f, metrics.headerHeight), bounds.height);
result.headerRect = UIRect(bounds.x, bounds.y, bounds.width, headerHeight);
result.contentRect = UIRect(
bounds.x,
bounds.y + headerHeight,
bounds.width,
(std::max)(0.0f, bounds.height - headerHeight));
result.tabHeaderRects.resize(desiredHeaderWidths.size());
if (desiredHeaderWidths.empty()) {
return result;
}
const float gap = (std::max)(0.0f, metrics.tabGap);
const float totalGapWidth = gap * static_cast<float>(desiredHeaderWidths.size() - 1u);
const float availableTabsWidth = (std::max)(0.0f, bounds.width - totalGapWidth);
float totalDesiredWidth = 0.0f;
for (float width : desiredHeaderWidths) {
totalDesiredWidth += (std::max)(0.0f, width);
}
const float scale = totalDesiredWidth > 0.0f && totalDesiredWidth > availableTabsWidth
? availableTabsWidth / totalDesiredWidth
: 1.0f;
float cursorX = bounds.x;
for (std::size_t index = 0; index < desiredHeaderWidths.size(); ++index) {
const float width = totalDesiredWidth > 0.0f
? (std::max)(0.0f, desiredHeaderWidths[index]) * scale
: availableTabsWidth / static_cast<float>(desiredHeaderWidths.size());
result.tabHeaderRects[index] = UIRect(cursorX, bounds.y, width, headerHeight);
cursorX += width + gap;
}
return result;
}
} // namespace Layout
} // namespace UI
} // namespace XCEngine

View File

@@ -4,6 +4,7 @@
#include <XCEngine/UI/Runtime/UIScreenTypes.h>
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
@@ -91,6 +92,7 @@ private:
UIInputDispatcher m_inputDispatcher;
std::unordered_map<std::string, float> m_verticalScrollOffsets = {};
std::unordered_map<std::string, float> m_splitterRatios = {};
std::unordered_map<std::string, std::size_t> m_tabStripSelectedIndices = {};
PointerState m_pointerState = {};
SplitterDragRuntimeState m_splitterDragState = {};
InputDebugSnapshot m_inputDebugSnapshot = {};

View File

@@ -0,0 +1,110 @@
#pragma once
#include <algorithm>
#include <cstddef>
namespace XCEngine {
namespace UI {
namespace Widgets {
class UITabStripModel {
public:
static constexpr std::size_t InvalidIndex = static_cast<std::size_t>(-1);
std::size_t GetItemCount() const {
return m_itemCount;
}
bool SetItemCount(std::size_t itemCount) {
m_itemCount = itemCount;
return ClampSelection();
}
bool HasSelection() const {
return m_itemCount > 0u && m_selectedIndex != InvalidIndex;
}
std::size_t GetSelectedIndex() const {
return m_selectedIndex;
}
bool SetSelectedIndex(std::size_t index) {
if (m_itemCount == 0u) {
index = InvalidIndex;
} else if (index >= m_itemCount) {
return false;
}
if (m_selectedIndex == index) {
return false;
}
m_selectedIndex = index;
return true;
}
bool SelectFirst() {
return SetSelectedIndex(m_itemCount > 0u ? 0u : InvalidIndex);
}
bool SelectLast() {
return SetSelectedIndex(m_itemCount > 0u ? (m_itemCount - 1u) : InvalidIndex);
}
bool SelectNext() {
if (m_itemCount == 0u) {
return false;
}
if (m_selectedIndex == InvalidIndex) {
m_selectedIndex = 0u;
return true;
}
return SetSelectedIndex((std::min)(m_selectedIndex + 1u, m_itemCount - 1u));
}
bool SelectPrevious() {
if (m_itemCount == 0u) {
return false;
}
if (m_selectedIndex == InvalidIndex) {
m_selectedIndex = 0u;
return true;
}
return SetSelectedIndex(m_selectedIndex > 0u ? m_selectedIndex - 1u : 0u);
}
private:
bool ClampSelection() {
if (m_itemCount == 0u) {
if (m_selectedIndex == InvalidIndex) {
return false;
}
m_selectedIndex = InvalidIndex;
return true;
}
if (m_selectedIndex == InvalidIndex) {
m_selectedIndex = 0u;
return true;
}
if (m_selectedIndex < m_itemCount) {
return false;
}
m_selectedIndex = m_itemCount - 1u;
return true;
}
std::size_t m_itemCount = 0u;
std::size_t m_selectedIndex = InvalidIndex;
};
} // namespace Widgets
} // namespace UI
} // namespace XCEngine

View File

@@ -5,6 +5,8 @@
#include <XCEngine/Core/Math/Color.h>
#include <XCEngine/Resources/UI/UIDocumentCompiler.h>
#include <XCEngine/UI/Layout/LayoutEngine.h>
#include <XCEngine/UI/Layout/UITabStripLayout.h>
#include <XCEngine/UI/Widgets/UITabStripModel.h>
#include <algorithm>
#include <cctype>
@@ -36,6 +38,7 @@ namespace Layout = XCEngine::UI::Layout;
constexpr float kDefaultFontSize = 16.0f;
constexpr float kSmallFontSize = 13.0f;
constexpr float kButtonFontSize = 14.0f;
constexpr float kTabFontSize = 13.0f;
constexpr float kApproximateGlyphWidth = 0.56f;
constexpr float kHeaderTextInset = 12.0f;
constexpr float kHeaderTextGap = 2.0f;
@@ -57,6 +60,8 @@ struct RuntimeLayoutNode {
bool wantsPointerCapture = false;
bool isScrollView = false;
bool isSplitter = false;
bool isTabStrip = false;
bool isTab = false;
bool textInput = false;
Layout::UILayoutAxis splitterAxis = Layout::UILayoutAxis::Horizontal;
Layout::UISplitterMetrics splitterMetrics = {};
@@ -64,6 +69,13 @@ struct RuntimeLayoutNode {
float splitterRatio = 0.5f;
UIRect splitterHandleRect = {};
UIRect splitterHandleHitRect = {};
Layout::UITabStripMetrics tabStripMetrics = {};
std::size_t tabStripSelectedIndex = 0u;
std::size_t tabIndex = 0u;
UIRect tabStripHeaderRect = {};
UIRect tabStripContentRect = {};
UIRect tabHeaderRect = {};
bool tabSelected = false;
bool hasShortcutBinding = false;
UIShortcutBinding shortcutBinding = {};
enum class ShortcutScopeRoot : std::uint8_t {
@@ -175,6 +187,8 @@ std::string GetAttribute(
return attribute != nullptr ? ToStdString(attribute->value) : fallback;
}
std::string ResolveNodeText(const UIDocumentNode& node);
bool ParseBoolAttribute(
const UIDocumentNode& node,
const char* name,
@@ -446,6 +460,42 @@ float MeasureHeaderHeight(const UIDocumentNode& node) {
return headerHeight;
}
bool TryParseIndexValue(const std::string& text, std::size_t& outValue) {
if (text.empty()) {
return false;
}
std::size_t value = 0u;
for (unsigned char ch : text) {
if (std::isspace(ch)) {
continue;
}
if (!std::isdigit(ch)) {
return false;
}
value = value * 10u + static_cast<std::size_t>(ch - '0');
}
outValue = value;
return true;
}
std::string ResolveTabLabelText(const UIDocumentNode& node) {
std::string label = GetAttribute(node, "label");
if (!label.empty()) {
return label;
}
label = GetAttribute(node, "title");
if (!label.empty()) {
return label;
}
return ResolveNodeText(node);
}
bool TryParseFloat(const std::string& text, float& outValue) {
if (text.empty()) {
return false;
@@ -468,6 +518,28 @@ bool TryParseFloat(const std::string& text, float& outValue) {
return true;
}
std::size_t ResolveInitialTabStripSelectedIndex(
const UIDocumentNode& node,
std::size_t childCount) {
if (childCount == 0u) {
return Widgets::UITabStripModel::InvalidIndex;
}
std::size_t attributeIndex = 0u;
if (TryParseIndexValue(GetAttribute(node, "selectedIndex"), attributeIndex) &&
attributeIndex < childCount) {
return attributeIndex;
}
for (std::size_t index = 0; index < childCount; ++index) {
if (ParseBoolAttribute(node.children[index], "selected", false)) {
return index;
}
}
return 0u;
}
float ParseFloatAttribute(
const UIDocumentNode& node,
const char* name,
@@ -577,6 +649,47 @@ std::string ResolveNodeText(const UIDocumentNode& node) {
return ToStdString(node.tagName);
}
std::size_t ResolveTabStripSelectedIndex(
const UIDocumentNode& source,
const std::string& stateKey,
std::size_t childCount,
const std::unordered_map<std::string, std::size_t>& tabStripSelectedIndices) {
Widgets::UITabStripModel model = {};
model.SetItemCount(childCount);
const auto found = tabStripSelectedIndices.find(stateKey);
if (found != tabStripSelectedIndices.end() &&
found->second != Widgets::UITabStripModel::InvalidIndex) {
model.SetSelectedIndex(found->second);
} else {
const std::size_t initialIndex = ResolveInitialTabStripSelectedIndex(source, childCount);
if (initialIndex != Widgets::UITabStripModel::InvalidIndex) {
model.SetSelectedIndex(initialIndex);
}
}
return model.HasSelection() ? model.GetSelectedIndex() : 0u;
}
void ApplyTabStripSelection(RuntimeLayoutNode& tabStripNode, std::size_t selectedIndex) {
Widgets::UITabStripModel model = {};
model.SetItemCount(tabStripNode.children.size());
if (!tabStripNode.children.empty()) {
model.SetSelectedIndex((std::min)(selectedIndex, tabStripNode.children.size() - 1u));
}
tabStripNode.tabStripSelectedIndex = model.HasSelection() ? model.GetSelectedIndex() : 0u;
for (std::size_t index = 0; index < tabStripNode.children.size(); ++index) {
RuntimeLayoutNode& child = tabStripNode.children[index];
child.tabIndex = index;
child.tabSelected = model.HasSelection() && index == model.GetSelectedIndex();
}
}
bool AreTabChildrenVisible(const RuntimeLayoutNode& node) {
return !node.isTab || node.tabSelected;
}
bool IsHorizontalTag(const std::string& tagName) {
return tagName == "Row";
}
@@ -589,6 +702,14 @@ bool IsSplitterTag(const std::string& tagName) {
return tagName == "Splitter";
}
bool IsTabStripTag(const std::string& tagName) {
return tagName == "TabStrip";
}
bool IsTabTag(const std::string& tagName) {
return tagName == "Tab";
}
bool IsButtonTag(const std::string& tagName) {
return tagName == "Button";
}
@@ -625,6 +746,27 @@ Layout::UISplitterMetrics ParseSplitterMetrics(const UIDocumentNode& node) {
return metrics;
}
Layout::UITabStripMetrics ParseTabStripMetrics(const UIDocumentNode& node) {
Layout::UITabStripMetrics metrics = {};
metrics.headerHeight = (std::max)(
24.0f,
ParseFloatAttributeAny(
node,
"tabHeaderHeight",
"tabHeight",
ParseFloatAttribute(node, "headerHeight", metrics.headerHeight)));
metrics.tabMinWidth = (std::max)(
32.0f,
ParseFloatAttributeAny(node, "tabMinWidth", "minTabWidth", metrics.tabMinWidth));
metrics.tabHorizontalPadding = (std::max)(
0.0f,
ParseFloatAttributeAny(node, "tabPaddingX", "tabHorizontalPadding", metrics.tabHorizontalPadding));
metrics.tabGap = (std::max)(
0.0f,
ParseFloatAttributeAny(node, "tabGap", "headerGap", metrics.tabGap));
return metrics;
}
bool IsContainerTag(const UIDocumentNode& node) {
if (node.children.Size() > 0u) {
return true;
@@ -635,6 +777,8 @@ bool IsContainerTag(const UIDocumentNode& node) {
tagName == "Column" ||
tagName == "Row" ||
tagName == "Splitter" ||
tagName == "TabStrip" ||
tagName == "Tab" ||
tagName == "ScrollView" ||
tagName == "Card" ||
tagName == "Button";
@@ -645,12 +789,12 @@ bool IsPointerInteractiveNode(const UIDocumentNode& node) {
return ParseBoolAttribute(
node,
"interactive",
IsButtonTag(tagName) || IsScrollViewTag(tagName) || IsSplitterTag(tagName));
IsButtonTag(tagName) || IsScrollViewTag(tagName) || IsSplitterTag(tagName) || IsTabTag(tagName));
}
bool IsFocusableNode(const UIDocumentNode& node) {
const std::string tagName = ToStdString(node.tagName);
return ParseBoolAttribute(node, "focusable", IsButtonTag(tagName));
return ParseBoolAttribute(node, "focusable", IsButtonTag(tagName) || IsTabTag(tagName));
}
bool WantsPointerCapture(const UIDocumentNode& node) {
@@ -713,11 +857,15 @@ const UIRect& GetNodeInteractionRect(const RuntimeLayoutNode& node) {
return node.splitterHandleHitRect;
}
if (node.isTab) {
return node.tabHeaderRect;
}
return node.isScrollView ? node.scrollViewportRect : node.rect;
}
bool IsNodeTargetable(const RuntimeLayoutNode& node) {
return node.pointerInteractive || node.focusable || node.isScrollView || node.isSplitter;
return node.pointerInteractive || node.focusable || node.isScrollView || node.isSplitter || node.isTab;
}
const RuntimeLayoutNode* FindNodeByElementId(
@@ -752,6 +900,37 @@ RuntimeLayoutNode* FindNodeByElementId(
return nullptr;
}
RuntimeLayoutNode* FindParentTabStripNode(RuntimeLayoutNode& root, const RuntimeLayoutNode& node) {
if (!node.isTab || node.inputPath.elements.size() < 2u) {
return nullptr;
}
RuntimeLayoutNode* parent = FindNodeByElementId(
root,
node.inputPath.elements[node.inputPath.elements.size() - 2u]);
return parent != nullptr && parent->isTabStrip ? parent : nullptr;
}
const RuntimeLayoutNode* FindVisibleNodeByElementId(
const RuntimeLayoutNode& node,
UIElementId elementId) {
if (node.elementId == elementId) {
return &node;
}
if (!AreTabChildrenVisible(node)) {
return nullptr;
}
for (const RuntimeLayoutNode& child : node.children) {
if (const RuntimeLayoutNode* found = FindVisibleNodeByElementId(child, elementId); found != nullptr) {
return found;
}
}
return nullptr;
}
bool CollectNodesForInputPath(
const RuntimeLayoutNode& node,
const UIInputPath& path,
@@ -766,6 +945,11 @@ bool CollectNodesForInputPath(
return true;
}
if (!AreTabChildrenVisible(node)) {
outNodes.pop_back();
return false;
}
for (const RuntimeLayoutNode& child : node.children) {
if (CollectNodesForInputPath(child, path, index + 1u, outNodes)) {
return true;
@@ -793,7 +977,7 @@ std::string ResolveStateKeyForElementId(
bool PathTargetExists(
const RuntimeLayoutNode& root,
const UIInputPath& path) {
return !path.Empty() && FindNodeByElementId(root, path.Target()) != nullptr;
return !path.Empty() && FindVisibleNodeByElementId(root, path.Target()) != nullptr;
}
bool SplitterTargetExists(
@@ -843,7 +1027,26 @@ std::string ValidateRuntimeLayoutTree(const RuntimeLayoutNode& node) {
return "Splitter '" + splitterName + "' must contain exactly 2 child elements.";
}
if (node.isTabStrip) {
const std::string tabStripName = node.stateKey.empty()
? std::string("<unnamed-tab-strip>")
: node.stateKey;
if (node.children.empty()) {
return "TabStrip '" + tabStripName + "' must contain at least 1 Tab child.";
}
for (const RuntimeLayoutNode& child : node.children) {
if (!child.isTab) {
return "TabStrip '" + tabStripName + "' may only contain Tab children.";
}
}
}
for (const RuntimeLayoutNode& child : node.children) {
if (child.isTab && !node.isTabStrip) {
return "Tab '" + child.stateKey + "' must be parented directly by a TabStrip.";
}
const std::string error = ValidateRuntimeLayoutTree(child);
if (!error.empty()) {
return error;
@@ -923,6 +1126,10 @@ void RegisterShortcutBindings(
registry.RegisterBinding(node.shortcutBinding);
}
if (!AreTabChildrenVisible(node)) {
return;
}
for (const RuntimeLayoutNode& child : node.children) {
RegisterShortcutBindings(child, registry);
}
@@ -964,6 +1171,10 @@ void CollectFocusablePaths(
outPaths.push_back(node.inputPath);
}
if (!AreTabChildrenVisible(node)) {
return;
}
for (const RuntimeLayoutNode& child : node.children) {
CollectFocusablePaths(child, outPaths);
}
@@ -1036,11 +1247,13 @@ const RuntimeLayoutNode* FindDeepestInputTarget(
return &node;
}
if (AreTabChildrenVisible(node)) {
for (const RuntimeLayoutNode& child : node.children) {
if (const RuntimeLayoutNode* found = FindDeepestInputTarget(child, point, &nextClip); found != nullptr) {
return found;
}
}
}
if (!IsNodeTargetable(node) || !HasPositiveArea(targetRect)) {
return nullptr;
@@ -1149,8 +1362,10 @@ RuntimeLayoutNode BuildLayoutTree(
const UIDocumentNode& source,
const std::string& parentStateKey,
const UIInputPath& parentInputPath,
std::size_t siblingIndex) {
std::size_t siblingIndex,
const std::unordered_map<std::string, std::size_t>& tabStripSelectedIndices) {
RuntimeLayoutNode node = {};
const std::string tagName = ToStdString(source.tagName);
node.source = &source;
node.stateKey = parentStateKey + "/" + BuildNodeStateKeySegment(source, siblingIndex);
node.elementId = HashStateKeyToElementId(node.stateKey);
@@ -1159,11 +1374,14 @@ RuntimeLayoutNode BuildLayoutTree(
node.pointerInteractive = IsPointerInteractiveNode(source);
node.focusable = ParseBoolAttribute(source, "focusable", IsFocusableNode(source));
node.wantsPointerCapture = WantsPointerCapture(source);
node.isScrollView = IsScrollViewTag(ToStdString(source.tagName));
node.isSplitter = IsSplitterTag(ToStdString(source.tagName));
node.isScrollView = IsScrollViewTag(tagName);
node.isSplitter = IsSplitterTag(tagName);
node.isTabStrip = IsTabStripTag(tagName);
node.isTab = IsTabTag(tagName);
node.textInput = IsTextInputNode(source);
node.splitterAxis = ParseAxisAttribute(source, "axis", Layout::UILayoutAxis::Horizontal);
node.splitterMetrics = ParseSplitterMetrics(source);
node.tabStripMetrics = ParseTabStripMetrics(source);
node.splitterRatio = ParseRatioAttribute(
source,
"splitRatio",
@@ -1172,8 +1390,24 @@ RuntimeLayoutNode BuildLayoutTree(
node.hasShortcutBinding = TryBuildShortcutBinding(source, node.elementId, node.shortcutBinding);
node.children.reserve(source.children.Size());
for (std::size_t index = 0; index < source.children.Size(); ++index) {
node.children.push_back(BuildLayoutTree(source.children[index], node.stateKey, node.inputPath, index));
node.children.push_back(BuildLayoutTree(
source.children[index],
node.stateKey,
node.inputPath,
index,
tabStripSelectedIndices));
}
if (node.isTabStrip) {
ApplyTabStripSelection(
node,
ResolveTabStripSelectedIndex(
source,
node.stateKey,
node.children.size(),
tabStripSelectedIndices));
}
return node;
}
@@ -1260,6 +1494,40 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
return node.desiredSize;
}
if (node.isTabStrip) {
std::vector<Layout::UITabStripMeasureItem> items = {};
items.reserve(node.children.size());
for (RuntimeLayoutNode& child : node.children) {
MeasureNode(child);
Layout::UITabStripMeasureItem item = {};
item.desiredHeaderLabelWidth = MeasureTextWidth(
ResolveTabLabelText(*child.source),
kTabFontSize);
item.desiredContentSize = child.desiredSize;
item.minimumContentSize = child.minimumSize;
items.push_back(item);
}
const Layout::UITabStripMeasureResult measured = Layout::MeasureUITabStrip(
items,
node.tabStripMetrics);
node.contentDesiredSize = measured.desiredSize;
node.desiredSize = measured.desiredSize;
node.minimumSize = measured.minimumSize;
float explicitWidth = 0.0f;
if (TryParseFloat(GetAttribute(source, "width"), explicitWidth)) {
node.desiredSize.width = (std::max)(node.desiredSize.width, explicitWidth);
}
float explicitHeight = 0.0f;
if (TryParseFloat(GetAttribute(source, "height"), explicitHeight)) {
node.desiredSize.height = (std::max)(node.desiredSize.height, explicitHeight);
}
return node.desiredSize;
}
Layout::UIStackLayoutOptions options = {};
options.axis = IsHorizontalTag(tagName)
? Layout::UILayoutAxis::Horizontal
@@ -1288,20 +1556,22 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
node.desiredSize = measured.desiredSize;
node.minimumSize = minimumMeasured.desiredSize;
const float headerHeight = MeasureHeaderHeight(source);
const float headerTextWidth = MeasureHeaderTextWidth(source);
const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source);
const float headerTextWidth = node.isTab ? 0.0f : MeasureHeaderTextWidth(source);
if (!node.isTab) {
node.desiredSize.width = (std::max)(
node.desiredSize.width,
headerTextWidth > 0.0f
? headerTextWidth + options.padding.Horizontal()
: MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal());
node.desiredSize.height += headerHeight;
node.minimumSize.width = (std::max)(
node.minimumSize.width,
headerTextWidth > 0.0f
? headerTextWidth + options.padding.Horizontal()
: MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal());
}
node.desiredSize.height += headerHeight;
node.minimumSize.height += headerHeight;
if (node.isScrollView) {
@@ -1331,6 +1601,9 @@ void ArrangeNode(
node.scrollOffsetY = 0.0f;
node.splitterHandleRect = {};
node.splitterHandleHitRect = {};
node.tabStripHeaderRect = {};
node.tabStripContentRect = {};
node.tabHeaderRect = {};
const UIDocumentNode& source = *node.source;
if (!IsContainerTag(source)) {
@@ -1367,6 +1640,37 @@ void ArrangeNode(
return;
}
if (node.isTabStrip) {
std::vector<float> desiredHeaderWidths = {};
desiredHeaderWidths.reserve(node.children.size());
for (const RuntimeLayoutNode& child : node.children) {
desiredHeaderWidths.push_back(Layout::MeasureUITabStripHeaderWidth(
MeasureTextWidth(ResolveTabLabelText(*child.source), kTabFontSize),
node.tabStripMetrics));
}
const Layout::UITabStripLayoutResult arranged = Layout::ArrangeUITabStrip(
rect,
desiredHeaderWidths,
node.tabStripMetrics);
node.tabStripHeaderRect = arranged.headerRect;
node.tabStripContentRect = arranged.contentRect;
for (std::size_t index = 0; index < node.children.size(); ++index) {
RuntimeLayoutNode& child = node.children[index];
child.tabIndex = index;
child.tabSelected = index == node.tabStripSelectedIndex;
ArrangeNode(
child,
arranged.contentRect,
verticalScrollOffsets,
splitterRatios);
child.tabHeaderRect = index < arranged.tabHeaderRects.size()
? arranged.tabHeaderRects[index]
: UIRect();
}
return;
}
const std::string tagName = ToStdString(source.tagName);
Layout::UIStackLayoutOptions options = {};
options.axis = IsHorizontalTag(tagName)
@@ -1380,7 +1684,7 @@ void ArrangeNode(
source,
tagName == "View" ? 16.0f : 12.0f);
const float headerHeight = MeasureHeaderHeight(source);
const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source);
UIRect contentRect = rect;
contentRect.y += headerHeight;
@@ -1428,11 +1732,13 @@ void ArrangeNode(
RuntimeLayoutNode* FindDeepestScrollTarget(
RuntimeLayoutNode& node,
const UIPoint& point) {
if (AreTabChildrenVisible(node)) {
for (RuntimeLayoutNode& child : node.children) {
if (RuntimeLayoutNode* target = FindDeepestScrollTarget(child, point); target != nullptr) {
return target;
}
}
}
if (!node.isScrollView) {
return nullptr;
@@ -1452,11 +1758,13 @@ RuntimeLayoutNode* FindDeepestScrollTarget(
RuntimeLayoutNode* FindDeepestHoveredScrollView(
RuntimeLayoutNode& node,
const UIPoint& point) {
if (AreTabChildrenVisible(node)) {
for (RuntimeLayoutNode& child : node.children) {
if (RuntimeLayoutNode* hovered = FindDeepestHoveredScrollView(child, point); hovered != nullptr) {
return hovered;
}
}
}
if (!node.isScrollView) {
return nullptr;
@@ -1472,11 +1780,13 @@ const RuntimeLayoutNode* FindFirstScrollView(const RuntimeLayoutNode& node) {
return &node;
}
if (AreTabChildrenVisible(node)) {
for (const RuntimeLayoutNode& child : node.children) {
if (const RuntimeLayoutNode* found = FindFirstScrollView(child); found != nullptr) {
return found;
}
}
}
return nullptr;
}
@@ -1672,12 +1982,153 @@ void UpdateInputDebugSnapshot(
shortcutContext.commandScope.widgetId);
}
enum class TabStripSelectionCommand : std::uint8_t {
None = 0,
SelectPrevious,
SelectNext,
SelectFirst,
SelectLast
};
TabStripSelectionCommand ResolveTabStripSelectionCommand(const UIInputEvent& event) {
if (event.type != UIInputEventType::KeyDown ||
event.modifiers.control ||
event.modifiers.alt ||
event.modifiers.super) {
return TabStripSelectionCommand::None;
}
switch (static_cast<KeyCode>(event.keyCode)) {
case KeyCode::Left:
return TabStripSelectionCommand::SelectPrevious;
case KeyCode::Right:
return TabStripSelectionCommand::SelectNext;
case KeyCode::Home:
return TabStripSelectionCommand::SelectFirst;
case KeyCode::End:
return TabStripSelectionCommand::SelectLast;
default:
return TabStripSelectionCommand::None;
}
}
bool UpdateTabStripSelection(
RuntimeLayoutNode& tabStripNode,
Widgets::UITabStripModel& model,
std::unordered_map<std::string, std::size_t>& tabStripSelectedIndices,
UIFocusController& focusController) {
ApplyTabStripSelection(tabStripNode, model.HasSelection() ? model.GetSelectedIndex() : 0u);
if (model.HasSelection()) {
tabStripSelectedIndices[tabStripNode.stateKey] = model.GetSelectedIndex();
focusController.SetFocusedPath(tabStripNode.children[model.GetSelectedIndex()].inputPath);
} else {
tabStripSelectedIndices.erase(tabStripNode.stateKey);
focusController.ClearFocus();
}
focusController.ClearActivePath();
return model.HasSelection();
}
const RuntimeLayoutNode* FindInnermostVisibleTabNodeForPath(
const RuntimeLayoutNode& root,
const UIInputPath& path) {
if (path.Empty()) {
return nullptr;
}
std::vector<const RuntimeLayoutNode*> nodes = {};
if (!CollectNodesForInputPath(root, path, 0u, nodes)) {
return nullptr;
}
for (auto iter = nodes.rbegin(); iter != nodes.rend(); ++iter) {
if ((*iter)->isTab) {
return *iter;
}
}
return nullptr;
}
bool TryHandleFocusedTabStripSelectionCommand(
RuntimeLayoutNode& root,
const UIInputEvent& event,
UIInputDispatcher& inputDispatcher,
std::unordered_map<std::string, std::size_t>& tabStripSelectedIndices,
UIDocumentScreenHost::InputDebugSnapshot& inputDebugSnapshot,
bool& layoutChanged) {
layoutChanged = false;
if (inputDispatcher.GetShortcutContext().textInputActive) {
return false;
}
const TabStripSelectionCommand selectionCommand = ResolveTabStripSelectionCommand(event);
if (selectionCommand == TabStripSelectionCommand::None) {
return false;
}
const RuntimeLayoutNode* focusedTabNode = FindInnermostVisibleTabNodeForPath(
root,
inputDispatcher.GetFocusController().GetFocusedPath());
if (focusedTabNode == nullptr) {
return false;
}
RuntimeLayoutNode* focusedTab = FindNodeByElementId(root, focusedTabNode->elementId);
if (focusedTab == nullptr) {
return false;
}
RuntimeLayoutNode* tabStripNode = FindParentTabStripNode(root, *focusedTab);
if (tabStripNode == nullptr || tabStripNode->children.empty()) {
return false;
}
Widgets::UITabStripModel model = {};
model.SetItemCount(tabStripNode->children.size());
model.SetSelectedIndex((std::min)(focusedTab->tabIndex, tabStripNode->children.size() - 1u));
switch (selectionCommand) {
case TabStripSelectionCommand::SelectPrevious:
layoutChanged = model.SelectPrevious();
break;
case TabStripSelectionCommand::SelectNext:
layoutChanged = model.SelectNext();
break;
case TabStripSelectionCommand::SelectFirst:
layoutChanged = model.SelectFirst();
break;
case TabStripSelectionCommand::SelectLast:
layoutChanged = model.SelectLast();
break;
case TabStripSelectionCommand::None:
default:
return false;
}
UpdateTabStripSelection(
*tabStripNode,
model,
tabStripSelectedIndices,
inputDispatcher.GetFocusController());
const RuntimeLayoutNode& targetTab = tabStripNode->children[tabStripNode->tabStripSelectedIndex];
inputDebugSnapshot.lastTargetKind = "Focused";
inputDebugSnapshot.lastTargetStateKey = targetTab.stateKey;
inputDebugSnapshot.lastResult = layoutChanged
? "Tab navigated"
: "Tab navigation unchanged";
return true;
}
bool DispatchInputEvent(
RuntimeLayoutNode& root,
const UIInputEvent& event,
const UIInputPath& hoveredPath,
UIInputDispatcher& inputDispatcher,
std::unordered_map<std::string, float>& splitterRatios,
std::unordered_map<std::string, std::size_t>& tabStripSelectedIndices,
UIDocumentScreenHost::SplitterDragRuntimeState& splitterDragState,
UIDocumentScreenHost::InputDebugSnapshot& inputDebugSnapshot) {
if (event.type == UIInputEventType::PointerWheel) {
@@ -1725,9 +2176,22 @@ bool DispatchInputEvent(
return false;
}
bool focusedTabNavigationChanged = false;
if (TryHandleFocusedTabStripSelectionCommand(
root,
event,
inputDispatcher,
tabStripSelectedIndices,
inputDebugSnapshot,
focusedTabNavigationChanged)) {
return focusedTabNavigationChanged;
}
bool pointerCaptureStarted = false;
bool layoutChanged = false;
bool splitterDragFinished = false;
bool tabSelectionTriggered = false;
bool tabSelectionChanged = false;
const UIInputDispatchSummary summary = inputDispatcher.Dispatch(
event,
hoveredPath,
@@ -1736,7 +2200,7 @@ bool DispatchInputEvent(
return UIInputDispatchDecision{};
}
const RuntimeLayoutNode* node = FindNodeByElementId(root, request.elementId);
RuntimeLayoutNode* node = FindNodeByElementId(root, request.elementId);
if (node == nullptr) {
return UIInputDispatchDecision{};
}
@@ -1811,6 +2275,25 @@ bool DispatchInputEvent(
return UIInputDispatchDecision{ true, false };
}
if (node->isTab &&
event.type == UIInputEventType::PointerButtonUp &&
event.pointerButton == UIPointerButton::Left) {
if (RuntimeLayoutNode* tabStripNode = FindParentTabStripNode(root, *node);
tabStripNode != nullptr) {
tabSelectionTriggered = true;
tabSelectionChanged = tabSelectionChanged || tabStripNode->tabStripSelectedIndex != node->tabIndex;
Widgets::UITabStripModel model = {};
model.SetItemCount(tabStripNode->children.size());
model.SetSelectedIndex(node->tabIndex);
UpdateTabStripSelection(
*tabStripNode,
model,
tabStripSelectedIndices,
inputDispatcher.GetFocusController());
return UIInputDispatchDecision{ true, false };
}
}
if (event.type == UIInputEventType::PointerButtonDown &&
event.pointerButton == UIPointerButton::Left &&
node->wantsPointerCapture) {
@@ -1846,6 +2329,10 @@ bool DispatchInputEvent(
inputDebugSnapshot.lastResult = "Shortcut suppressed by text input";
} else if (focusTraversalSuppressed) {
inputDebugSnapshot.lastResult = "Focus traversal suppressed by text input";
} else if (tabSelectionTriggered) {
inputDebugSnapshot.lastResult = tabSelectionChanged
? "Tab selected"
: "Tab selection unchanged";
} else if (pointerCaptureStarted) {
inputDebugSnapshot.lastResult = splitterDragState.drag.active
? "Splitter drag started"
@@ -1899,6 +2386,18 @@ void SyncSplitterRatios(
}
}
void SyncTabStripSelectedIndices(
const RuntimeLayoutNode& node,
std::unordered_map<std::string, std::size_t>& tabStripSelectedIndices) {
if (node.isTabStrip && !node.children.empty()) {
tabStripSelectedIndices[node.stateKey] = node.tabStripSelectedIndex;
}
for (const RuntimeLayoutNode& child : node.children) {
SyncTabStripSelectedIndices(child, tabStripSelectedIndices);
}
}
void EmitNode(
const RuntimeLayoutNode& node,
const UIInputPath& hoveredPath,
@@ -1927,6 +2426,75 @@ void EmitNode(
}
}
if (node.isTabStrip) {
if (HasPositiveArea(node.rect)) {
drawList.AddFilledRect(node.rect, ToUIColor(Color(0.18f, 0.18f, 0.18f, 1.0f)), 8.0f);
++stats.filledRectCommandCount;
}
if (HasPositiveArea(node.tabStripHeaderRect)) {
drawList.AddFilledRect(node.tabStripHeaderRect, ToUIColor(Color(0.16f, 0.16f, 0.16f, 1.0f)), 8.0f);
++stats.filledRectCommandCount;
}
if (HasPositiveArea(node.tabStripContentRect)) {
drawList.AddFilledRect(node.tabStripContentRect, ToUIColor(Color(0.20f, 0.20f, 0.20f, 1.0f)), 8.0f);
++stats.filledRectCommandCount;
drawList.AddRectOutline(
node.tabStripContentRect,
ToUIColor(Color(0.26f, 0.26f, 0.26f, 1.0f)),
1.0f,
8.0f);
}
for (const RuntimeLayoutNode& child : node.children) {
if (!HasPositiveArea(child.tabHeaderRect)) {
continue;
}
const RuntimeNodeVisualState tabState = ResolveNodeVisualState(
child,
hoveredPath,
focusController);
Color fillColor = child.tabSelected
? Color(0.24f, 0.24f, 0.24f, 1.0f)
: Color(0.15f, 0.15f, 0.15f, 1.0f);
if (tabState.capture || tabState.active) {
fillColor = child.tabSelected
? Color(0.31f, 0.31f, 0.31f, 1.0f)
: Color(0.24f, 0.24f, 0.24f, 1.0f);
} else if (tabState.hovered) {
fillColor = child.tabSelected
? Color(0.28f, 0.28f, 0.28f, 1.0f)
: Color(0.20f, 0.20f, 0.20f, 1.0f);
}
drawList.AddFilledRect(child.tabHeaderRect, ToUIColor(fillColor), 6.0f);
++stats.filledRectCommandCount;
drawList.AddRectOutline(
child.tabHeaderRect,
ToUIColor(child.tabSelected
? Color(0.42f, 0.42f, 0.42f, 1.0f)
: Color(0.24f, 0.24f, 0.24f, 1.0f)),
tabState.focused || child.tabSelected ? 2.0f : 1.0f,
6.0f);
const std::string label = ResolveTabLabelText(*child.source);
if (!label.empty()) {
drawList.AddText(
UIPoint(
child.tabHeaderRect.x + node.tabStripMetrics.tabHorizontalPadding,
ComputeCenteredTextTop(child.tabHeaderRect, kTabFontSize)),
label,
ToUIColor(child.tabSelected
? Color(0.95f, 0.95f, 0.95f, 1.0f)
: Color(0.78f, 0.78f, 0.78f, 1.0f)),
kTabFontSize);
++stats.textCommandCount;
}
}
}
if (node.isSplitter && HasPositiveArea(node.splitterHandleRect)) {
const Color splitterColor =
visualState.capture || visualState.active
@@ -1948,8 +2516,8 @@ void EmitNode(
++stats.filledRectCommandCount;
}
const std::string title = GetAttribute(source, "title");
const std::string subtitle = GetAttribute(source, "subtitle");
const std::string title = (node.isTab || node.isTabStrip) ? std::string() : GetAttribute(source, "title");
const std::string subtitle = (node.isTab || node.isTabStrip) ? std::string() : GetAttribute(source, "subtitle");
float textY = node.rect.y + kHeaderTextInset;
if (!title.empty()) {
drawList.AddText(
@@ -1999,6 +2567,10 @@ void EmitNode(
}
for (const RuntimeLayoutNode& child : node.children) {
if (child.isTab && !child.tabSelected) {
continue;
}
EmitNode(child, hoveredPath, focusController, drawList, stats);
}
@@ -2119,7 +2691,12 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
const std::string stateRoot = document.sourcePath.empty()
? document.displayName
: document.sourcePath;
RuntimeLayoutNode root = BuildLayoutTree(document.viewDocument.rootNode, stateRoot, UIInputPath(), 0u);
RuntimeLayoutNode root = BuildLayoutTree(
document.viewDocument.rootNode,
stateRoot,
UIInputPath(),
0u,
m_tabStripSelectedIndices);
result.errorMessage = ValidateRuntimeLayoutTree(root);
if (!result.errorMessage.empty()) {
return result;
@@ -2191,6 +2768,7 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
eventHoveredPath,
m_inputDispatcher,
m_splitterRatios,
m_tabStripSelectedIndices,
m_splitterDragState,
m_inputDebugSnapshot)) {
ArrangeNode(root, viewportRect, m_verticalScrollOffsets, m_splitterRatios);
@@ -2219,6 +2797,8 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
SyncScrollOffsets(root, m_verticalScrollOffsets);
m_splitterRatios.clear();
SyncSplitterRatios(root, m_splitterRatios);
m_tabStripSelectedIndices.clear();
SyncTabStripSelectedIndices(root, m_tabStripSelectedIndices);
const UIFocusController& focusController = m_inputDispatcher.GetFocusController();

View File

@@ -13,6 +13,7 @@ set(NEW_EDITOR_RESOURCE_FILES
add_library(XCNewEditorLib STATIC
src/editor/EditorShellAsset.cpp
src/editor/UIEditorWorkspaceModel.cpp
src/Widgets/UIEditorCollectionPrimitives.cpp
)

View File

@@ -0,0 +1,108 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace XCEngine::NewEditor {
enum class UIEditorWorkspaceNodeKind : std::uint8_t {
Panel = 0,
TabStack,
Split
};
enum class UIEditorWorkspaceSplitAxis : std::uint8_t {
Horizontal = 0,
Vertical
};
struct UIEditorWorkspacePanelState {
std::string panelId = {};
std::string title = {};
bool placeholder = false;
};
struct UIEditorWorkspaceNode {
UIEditorWorkspaceNodeKind kind = UIEditorWorkspaceNodeKind::Panel;
std::string nodeId = {};
UIEditorWorkspaceSplitAxis splitAxis = UIEditorWorkspaceSplitAxis::Horizontal;
float splitRatio = 0.5f;
std::size_t selectedTabIndex = 0u;
UIEditorWorkspacePanelState panel = {};
std::vector<UIEditorWorkspaceNode> children = {};
};
struct UIEditorWorkspaceModel {
UIEditorWorkspaceNode root = {};
std::string activePanelId = {};
};
enum class UIEditorWorkspaceValidationCode : std::uint8_t {
None = 0,
EmptyNodeId,
InvalidSplitChildCount,
InvalidSplitRatio,
EmptyTabStack,
InvalidSelectedTabIndex,
NonPanelTabChild,
EmptyPanelId,
EmptyPanelTitle,
DuplicatePanelId,
InvalidActivePanelId
};
struct UIEditorWorkspaceValidationResult {
UIEditorWorkspaceValidationCode code = UIEditorWorkspaceValidationCode::None;
std::string message = {};
[[nodiscard]] bool IsValid() const {
return code == UIEditorWorkspaceValidationCode::None;
}
};
struct UIEditorWorkspaceVisiblePanel {
std::string panelId = {};
std::string title = {};
bool active = false;
bool placeholder = false;
};
UIEditorWorkspaceNode BuildUIEditorWorkspacePanel(
std::string nodeId,
std::string panelId,
std::string title,
bool placeholder = false);
UIEditorWorkspaceNode BuildUIEditorWorkspaceTabStack(
std::string nodeId,
std::vector<UIEditorWorkspaceNode> panels,
std::size_t selectedTabIndex = 0u);
UIEditorWorkspaceNode BuildUIEditorWorkspaceSplit(
std::string nodeId,
UIEditorWorkspaceSplitAxis axis,
float splitRatio,
UIEditorWorkspaceNode primary,
UIEditorWorkspaceNode secondary);
UIEditorWorkspaceValidationResult ValidateUIEditorWorkspace(
const UIEditorWorkspaceModel& workspace);
std::vector<UIEditorWorkspaceVisiblePanel> CollectUIEditorWorkspaceVisiblePanels(
const UIEditorWorkspaceModel& workspace);
bool ContainsUIEditorWorkspacePanel(
const UIEditorWorkspaceModel& workspace,
std::string_view panelId);
const UIEditorWorkspacePanelState* FindUIEditorWorkspaceActivePanel(
const UIEditorWorkspaceModel& workspace);
bool TryActivateUIEditorWorkspacePanel(
UIEditorWorkspaceModel& workspace,
std::string_view panelId);
} // namespace XCEngine::NewEditor

View File

@@ -42,6 +42,14 @@ void AutoScreenshotController::CaptureIfRequested(
}
std::error_code errorCode = {};
std::filesystem::create_directories(m_captureRoot, errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to create screenshot directory: " + m_captureRoot.string();
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
std::filesystem::create_directories(m_historyRoot, errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to create screenshot directory: " + m_historyRoot.string();
@@ -52,14 +60,26 @@ void AutoScreenshotController::CaptureIfRequested(
std::string captureError = {};
const std::filesystem::path historyPath = BuildHistoryCapturePath(m_pendingReason);
if (!renderer.CaptureToPng(drawData, width, height, m_latestCapturePath, captureError) ||
!renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) {
if (!renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) {
m_lastCaptureError = std::move(captureError);
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
errorCode.clear();
std::filesystem::copy_file(
historyPath,
m_latestCapturePath,
std::filesystem::copy_options::overwrite_existing,
errorCode);
if (errorCode) {
m_lastCaptureError = "Failed to update latest screenshot: " + m_latestCapturePath.string();
m_lastCaptureSummary = "AutoShot failed";
m_capturePending = false;
return;
}
++m_captureCount;
m_lastCaptureError.clear();
m_lastCaptureSummary =

View File

@@ -0,0 +1,298 @@
#include <XCNewEditor/Editor/UIEditorWorkspaceModel.h>
#include <cmath>
#include <unordered_set>
#include <utility>
namespace XCEngine::NewEditor {
namespace {
UIEditorWorkspaceValidationResult MakeValidationError(
UIEditorWorkspaceValidationCode code,
std::string message) {
UIEditorWorkspaceValidationResult result = {};
result.code = code;
result.message = std::move(message);
return result;
}
bool IsValidSplitRatio(float value) {
return std::isfinite(value) && value > 0.0f && value < 1.0f;
}
const UIEditorWorkspacePanelState* FindPanelRecursive(
const UIEditorWorkspaceNode& node,
std::string_view panelId) {
if (node.kind == UIEditorWorkspaceNodeKind::Panel) {
return node.panel.panelId == panelId ? &node.panel : nullptr;
}
for (const UIEditorWorkspaceNode& child : node.children) {
if (const UIEditorWorkspacePanelState* found = FindPanelRecursive(child, panelId)) {
return found;
}
}
return nullptr;
}
bool TryActivateRecursive(
UIEditorWorkspaceNode& node,
std::string_view panelId) {
switch (node.kind) {
case UIEditorWorkspaceNodeKind::Panel:
return node.panel.panelId == panelId;
case UIEditorWorkspaceNodeKind::TabStack:
for (std::size_t index = 0; index < node.children.size(); ++index) {
UIEditorWorkspaceNode& child = node.children[index];
if (child.kind == UIEditorWorkspaceNodeKind::Panel &&
child.panel.panelId == panelId) {
node.selectedTabIndex = index;
return true;
}
}
return false;
case UIEditorWorkspaceNodeKind::Split:
for (UIEditorWorkspaceNode& child : node.children) {
if (TryActivateRecursive(child, panelId)) {
return true;
}
}
return false;
}
return false;
}
void CollectVisiblePanelsRecursive(
const UIEditorWorkspaceNode& node,
std::string_view activePanelId,
std::vector<UIEditorWorkspaceVisiblePanel>& outPanels) {
switch (node.kind) {
case UIEditorWorkspaceNodeKind::Panel: {
UIEditorWorkspaceVisiblePanel panel = {};
panel.panelId = node.panel.panelId;
panel.title = node.panel.title;
panel.active = node.panel.panelId == activePanelId;
panel.placeholder = node.panel.placeholder;
outPanels.push_back(std::move(panel));
return;
}
case UIEditorWorkspaceNodeKind::TabStack:
if (node.selectedTabIndex < node.children.size()) {
CollectVisiblePanelsRecursive(
node.children[node.selectedTabIndex],
activePanelId,
outPanels);
}
return;
case UIEditorWorkspaceNodeKind::Split:
for (const UIEditorWorkspaceNode& child : node.children) {
CollectVisiblePanelsRecursive(child, activePanelId, outPanels);
}
return;
}
}
UIEditorWorkspaceValidationResult ValidateNodeRecursive(
const UIEditorWorkspaceNode& node,
std::unordered_set<std::string>& panelIds) {
if (node.nodeId.empty()) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::EmptyNodeId,
"Workspace node id must not be empty.");
}
switch (node.kind) {
case UIEditorWorkspaceNodeKind::Panel:
if (!node.children.empty()) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::NonPanelTabChild,
"Panel node '" + node.nodeId + "' must not contain child nodes.");
}
if (node.panel.panelId.empty()) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::EmptyPanelId,
"Panel node '" + node.nodeId + "' must define a panelId.");
}
if (node.panel.title.empty()) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::EmptyPanelTitle,
"Panel node '" + node.nodeId + "' must define a title.");
}
if (!panelIds.insert(node.panel.panelId).second) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::DuplicatePanelId,
"Panel id '" + node.panel.panelId + "' is duplicated in the workspace tree.");
}
return {};
case UIEditorWorkspaceNodeKind::TabStack:
if (node.children.empty()) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::EmptyTabStack,
"Tab stack '" + node.nodeId + "' must contain at least one panel.");
}
if (node.selectedTabIndex >= node.children.size()) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::InvalidSelectedTabIndex,
"Tab stack '" + node.nodeId + "' selectedTabIndex is out of range.");
}
for (const UIEditorWorkspaceNode& child : node.children) {
if (child.kind != UIEditorWorkspaceNodeKind::Panel) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::NonPanelTabChild,
"Tab stack '" + node.nodeId + "' may only contain panel leaf nodes.");
}
if (UIEditorWorkspaceValidationResult result = ValidateNodeRecursive(child, panelIds);
!result.IsValid()) {
return result;
}
}
return {};
case UIEditorWorkspaceNodeKind::Split:
if (node.children.size() != 2u) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::InvalidSplitChildCount,
"Split node '" + node.nodeId + "' must contain exactly two child nodes.");
}
if (!IsValidSplitRatio(node.splitRatio)) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::InvalidSplitRatio,
"Split node '" + node.nodeId + "' must define a ratio in the open interval (0, 1).");
}
for (const UIEditorWorkspaceNode& child : node.children) {
if (UIEditorWorkspaceValidationResult result = ValidateNodeRecursive(child, panelIds);
!result.IsValid()) {
return result;
}
}
return {};
}
return {};
}
} // namespace
UIEditorWorkspaceNode BuildUIEditorWorkspacePanel(
std::string nodeId,
std::string panelId,
std::string title,
bool placeholder) {
UIEditorWorkspaceNode node = {};
node.kind = UIEditorWorkspaceNodeKind::Panel;
node.nodeId = std::move(nodeId);
node.panel.panelId = std::move(panelId);
node.panel.title = std::move(title);
node.panel.placeholder = placeholder;
return node;
}
UIEditorWorkspaceNode BuildUIEditorWorkspaceTabStack(
std::string nodeId,
std::vector<UIEditorWorkspaceNode> panels,
std::size_t selectedTabIndex) {
UIEditorWorkspaceNode node = {};
node.kind = UIEditorWorkspaceNodeKind::TabStack;
node.nodeId = std::move(nodeId);
node.selectedTabIndex = selectedTabIndex;
node.children = std::move(panels);
return node;
}
UIEditorWorkspaceNode BuildUIEditorWorkspaceSplit(
std::string nodeId,
UIEditorWorkspaceSplitAxis axis,
float splitRatio,
UIEditorWorkspaceNode primary,
UIEditorWorkspaceNode secondary) {
UIEditorWorkspaceNode node = {};
node.kind = UIEditorWorkspaceNodeKind::Split;
node.nodeId = std::move(nodeId);
node.splitAxis = axis;
node.splitRatio = splitRatio;
node.children.push_back(std::move(primary));
node.children.push_back(std::move(secondary));
return node;
}
UIEditorWorkspaceValidationResult ValidateUIEditorWorkspace(
const UIEditorWorkspaceModel& workspace) {
std::unordered_set<std::string> panelIds = {};
UIEditorWorkspaceValidationResult result = ValidateNodeRecursive(workspace.root, panelIds);
if (!result.IsValid()) {
return result;
}
if (!workspace.activePanelId.empty()) {
const UIEditorWorkspacePanelState* activePanel = FindUIEditorWorkspaceActivePanel(workspace);
if (activePanel == nullptr) {
return MakeValidationError(
UIEditorWorkspaceValidationCode::InvalidActivePanelId,
"Active panel id '" + workspace.activePanelId + "' is missing or hidden by the current tab selection.");
}
}
return {};
}
std::vector<UIEditorWorkspaceVisiblePanel> CollectUIEditorWorkspaceVisiblePanels(
const UIEditorWorkspaceModel& workspace) {
std::vector<UIEditorWorkspaceVisiblePanel> visiblePanels = {};
CollectVisiblePanelsRecursive(workspace.root, workspace.activePanelId, visiblePanels);
return visiblePanels;
}
bool ContainsUIEditorWorkspacePanel(
const UIEditorWorkspaceModel& workspace,
std::string_view panelId) {
return FindPanelRecursive(workspace.root, panelId) != nullptr;
}
const UIEditorWorkspacePanelState* FindUIEditorWorkspaceActivePanel(
const UIEditorWorkspaceModel& workspace) {
if (workspace.activePanelId.empty()) {
return nullptr;
}
std::vector<UIEditorWorkspaceVisiblePanel> visiblePanels =
CollectUIEditorWorkspaceVisiblePanels(workspace);
for (const UIEditorWorkspaceVisiblePanel& panel : visiblePanels) {
if (panel.panelId == workspace.activePanelId) {
return FindPanelRecursive(workspace.root, workspace.activePanelId);
}
}
return nullptr;
}
bool TryActivateUIEditorWorkspacePanel(
UIEditorWorkspaceModel& workspace,
std::string_view panelId) {
if (!TryActivateRecursive(workspace.root, panelId)) {
return false;
}
workspace.activePanelId = std::string(panelId);
return true;
}
} // namespace XCEngine::NewEditor

View File

@@ -2,6 +2,8 @@ set(CORE_UI_TEST_SOURCES
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_shortcut_scope.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_interaction.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_model.cpp
# Migration bridge: legacy XCUI unit coverage still lives under tests/Core/UI
# until it is moved into tests/UI/Core/unit without changing behavior.
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_core.cpp

View File

@@ -0,0 +1,72 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Layout/UITabStripLayout.h>
namespace {
using XCEngine::UI::UIRect;
using XCEngine::UI::UISize;
using XCEngine::UI::Layout::ArrangeUITabStrip;
using XCEngine::UI::Layout::MeasureUITabStrip;
using XCEngine::UI::Layout::MeasureUITabStripHeaderWidth;
using XCEngine::UI::Layout::UITabStripMeasureItem;
using XCEngine::UI::Layout::UITabStripMetrics;
void ExpectRect(
const UIRect& rect,
float x,
float y,
float width,
float height) {
EXPECT_FLOAT_EQ(rect.x, x);
EXPECT_FLOAT_EQ(rect.y, y);
EXPECT_FLOAT_EQ(rect.width, width);
EXPECT_FLOAT_EQ(rect.height, height);
}
} // namespace
TEST(UITabStripLayoutTest, MeasureUsesTallestContentAndWidestHeaderBudget) {
const UITabStripMetrics metrics = { 32.0f, 80.0f, 10.0f, 2.0f };
const auto measured = MeasureUITabStrip(
{
UITabStripMeasureItem{ 36.0f, UISize(220.0f, 140.0f), UISize(120.0f, 80.0f) },
UITabStripMeasureItem{ 96.0f, UISize(180.0f, 200.0f), UISize(160.0f, 90.0f) }
},
metrics);
const float desiredHeaderWidth =
MeasureUITabStripHeaderWidth(36.0f, metrics) +
MeasureUITabStripHeaderWidth(96.0f, metrics) +
metrics.tabGap;
const float minimumHeaderWidth = metrics.tabMinWidth * 2.0f + metrics.tabGap;
EXPECT_FLOAT_EQ(measured.desiredSize.width, 220.0f);
EXPECT_FLOAT_EQ(measured.desiredSize.height, metrics.headerHeight + 200.0f);
EXPECT_FLOAT_EQ(measured.minimumSize.width, minimumHeaderWidth);
EXPECT_FLOAT_EQ(measured.minimumSize.height, metrics.headerHeight + 90.0f);
}
TEST(UITabStripLayoutTest, MeasureWithoutItemsReturnsZeroSize) {
const auto measured = MeasureUITabStrip({}, UITabStripMetrics{});
EXPECT_FLOAT_EQ(measured.desiredSize.width, 0.0f);
EXPECT_FLOAT_EQ(measured.desiredSize.height, 0.0f);
EXPECT_FLOAT_EQ(measured.minimumSize.width, 0.0f);
EXPECT_FLOAT_EQ(measured.minimumSize.height, 0.0f);
}
TEST(UITabStripLayoutTest, ArrangeScalesHeadersToAvailableWidthAndReservesContentArea) {
const UITabStripMetrics metrics = { 30.0f, 72.0f, 12.0f, 4.0f };
const auto arranged = ArrangeUITabStrip(
UIRect(10.0f, 20.0f, 180.0f, 120.0f),
{ 120.0f, 100.0f },
metrics);
ExpectRect(arranged.headerRect, 10.0f, 20.0f, 180.0f, 30.0f);
ExpectRect(arranged.contentRect, 10.0f, 50.0f, 180.0f, 90.0f);
ASSERT_EQ(arranged.tabHeaderRects.size(), 2u);
EXPECT_NEAR(arranged.tabHeaderRects[0].width + arranged.tabHeaderRects[1].width, 176.0f, 0.001f);
ExpectRect(arranged.tabHeaderRects[0], 10.0f, 20.0f, 96.0f, 30.0f);
ExpectRect(arranged.tabHeaderRects[1], 110.0f, 20.0f, 80.0f, 30.0f);
}

View File

@@ -0,0 +1,53 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/Widgets/UITabStripModel.h>
namespace {
using XCEngine::UI::Widgets::UITabStripModel;
} // namespace
TEST(UITabStripModelTest, SetItemCountInitializesAndClampsSelection) {
UITabStripModel model = {};
EXPECT_TRUE(model.SetItemCount(3u));
EXPECT_TRUE(model.HasSelection());
EXPECT_EQ(model.GetSelectedIndex(), 0u);
EXPECT_TRUE(model.SetSelectedIndex(2u));
EXPECT_EQ(model.GetSelectedIndex(), 2u);
EXPECT_TRUE(model.SetItemCount(2u));
EXPECT_EQ(model.GetSelectedIndex(), 1u);
}
TEST(UITabStripModelTest, NextPreviousFirstAndLastStayWithinBounds) {
UITabStripModel model = {};
ASSERT_TRUE(model.SetItemCount(3u));
EXPECT_TRUE(model.SelectLast());
EXPECT_EQ(model.GetSelectedIndex(), 2u);
EXPECT_FALSE(model.SelectNext());
EXPECT_EQ(model.GetSelectedIndex(), 2u);
EXPECT_TRUE(model.SelectPrevious());
EXPECT_EQ(model.GetSelectedIndex(), 1u);
EXPECT_TRUE(model.SelectFirst());
EXPECT_EQ(model.GetSelectedIndex(), 0u);
EXPECT_FALSE(model.SelectPrevious());
EXPECT_EQ(model.GetSelectedIndex(), 0u);
}
TEST(UITabStripModelTest, RejectsOutOfRangeSelectionAndClearsWhenEmpty) {
UITabStripModel model = {};
ASSERT_TRUE(model.SetItemCount(2u));
EXPECT_FALSE(model.SetSelectedIndex(3u));
EXPECT_EQ(model.GetSelectedIndex(), 0u);
EXPECT_TRUE(model.SetItemCount(0u));
EXPECT_FALSE(model.HasSelection());
EXPECT_EQ(model.GetSelectedIndex(), UITabStripModel::InvalidIndex);
EXPECT_FALSE(model.SetSelectedIndex(0u));
}

View File

@@ -1,6 +1,10 @@
add_subdirectory(splitter_resize)
add_subdirectory(tab_strip_selection)
add_subdirectory(workspace_compose)
add_custom_target(editor_ui_layout_integration_tests
DEPENDS
editor_ui_layout_splitter_resize_validation
editor_ui_layout_tab_strip_selection_validation
editor_ui_layout_workspace_compose_validation
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,8 +24,8 @@ fs::path RepoRelative(const char* relativePath) {
return (RepoRootPath() / relativePath).lexically_normal();
}
const std::array<EditorValidationScenario, 4>& GetEditorValidationScenarios() {
static const std::array<EditorValidationScenario, 4> scenarios = { {
const std::array<EditorValidationScenario, 6>& GetEditorValidationScenarios() {
static const std::array<EditorValidationScenario, 6> scenarios = { {
{
"editor.input.keyboard_focus",
UIValidationDomain::Editor,
@@ -61,6 +61,24 @@ const std::array<EditorValidationScenario, 4>& GetEditorValidationScenarios() {
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/captures")
},
{
"editor.layout.tab_strip_selection",
UIValidationDomain::Editor,
"layout",
"Editor Layout | TabStrip Selection",
RepoRelative("tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/tab_strip_selection/captures")
},
{
"editor.layout.workspace_compose",
UIValidationDomain::Editor,
"layout",
"Editor Layout | Workspace Compose",
RepoRelative("tests/UI/Editor/integration/layout/workspace_compose/View.xcui"),
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
RepoRelative("tests/UI/Editor/integration/layout/workspace_compose/captures")
}
} };
return scenarios;

View File

@@ -2,6 +2,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES
test_input_modifier_tracker.cpp
test_editor_validation_registry.cpp
test_structured_editor_shell.cpp
test_ui_editor_workspace_model.cpp
# Migration bridge: editor-facing XCUI primitive tests still reuse the
# legacy source location until they are relocated under tests/UI/Editor/unit.
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_collection_primitives.cpp

View File

@@ -17,19 +17,27 @@ TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExisti
const auto* keyboardScenario = FindEditorValidationScenario("editor.input.keyboard_focus");
const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope");
const auto* splitterScenario = FindEditorValidationScenario("editor.layout.splitter_resize");
const auto* tabStripScenario = FindEditorValidationScenario("editor.layout.tab_strip_selection");
const auto* workspaceScenario = FindEditorValidationScenario("editor.layout.workspace_compose");
ASSERT_NE(pointerScenario, nullptr);
ASSERT_NE(keyboardScenario, nullptr);
ASSERT_NE(shortcutScenario, nullptr);
ASSERT_NE(splitterScenario, nullptr);
ASSERT_NE(tabStripScenario, nullptr);
ASSERT_NE(workspaceScenario, nullptr);
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(tabStripScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(workspaceScenario->domain, UIValidationDomain::Editor);
EXPECT_EQ(pointerScenario->categoryId, "input");
EXPECT_EQ(keyboardScenario->categoryId, "input");
EXPECT_EQ(shortcutScenario->categoryId, "input");
EXPECT_EQ(splitterScenario->categoryId, "layout");
EXPECT_EQ(tabStripScenario->categoryId, "layout");
EXPECT_EQ(workspaceScenario->categoryId, "layout");
EXPECT_TRUE(std::filesystem::exists(pointerScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath));
@@ -38,6 +46,10 @@ TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExisti
EXPECT_TRUE(std::filesystem::exists(shortcutScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(splitterScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(tabStripScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(tabStripScenario->themePath));
EXPECT_TRUE(std::filesystem::exists(workspaceScenario->documentPath));
EXPECT_TRUE(std::filesystem::exists(workspaceScenario->themePath));
}
TEST(EditorValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) {

View File

@@ -0,0 +1,143 @@
#include <gtest/gtest.h>
#include <XCNewEditor/Editor/UIEditorWorkspaceModel.h>
#include <algorithm>
#include <string>
#include <vector>
namespace {
using XCEngine::NewEditor::BuildUIEditorWorkspacePanel;
using XCEngine::NewEditor::BuildUIEditorWorkspaceSplit;
using XCEngine::NewEditor::BuildUIEditorWorkspaceTabStack;
using XCEngine::NewEditor::CollectUIEditorWorkspaceVisiblePanels;
using XCEngine::NewEditor::ContainsUIEditorWorkspacePanel;
using XCEngine::NewEditor::FindUIEditorWorkspaceActivePanel;
using XCEngine::NewEditor::TryActivateUIEditorWorkspacePanel;
using XCEngine::NewEditor::UIEditorWorkspaceModel;
using XCEngine::NewEditor::UIEditorWorkspaceNodeKind;
using XCEngine::NewEditor::UIEditorWorkspaceSplitAxis;
using XCEngine::NewEditor::UIEditorWorkspaceValidationCode;
using XCEngine::NewEditor::ValidateUIEditorWorkspace;
std::vector<std::string> CollectVisiblePanelIds(const UIEditorWorkspaceModel& workspace) {
const auto panels = CollectUIEditorWorkspaceVisiblePanels(workspace);
std::vector<std::string> ids = {};
ids.reserve(panels.size());
for (const auto& panel : panels) {
ids.push_back(panel.panelId);
}
return ids;
}
} // namespace
TEST(UIEditorWorkspaceModelTest, ValidationRejectsSplitWithoutExactlyTwoChildren) {
UIEditorWorkspaceModel workspace = {};
workspace.root.kind = UIEditorWorkspaceNodeKind::Split;
workspace.root.nodeId = "root-split";
workspace.root.splitAxis = UIEditorWorkspaceSplitAxis::Horizontal;
workspace.root.splitRatio = 0.5f;
workspace.root.children.push_back(
BuildUIEditorWorkspacePanel("panel-a-node", "panel-a", "Panel A", true));
const auto result = ValidateUIEditorWorkspace(workspace);
EXPECT_EQ(result.code, UIEditorWorkspaceValidationCode::InvalidSplitChildCount);
}
TEST(UIEditorWorkspaceModelTest, ValidationRejectsTabStackWithNestedSplitChild) {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceTabStack(
"root-tabs",
{
BuildUIEditorWorkspacePanel("panel-a-node", "panel-a", "Panel A", true),
BuildUIEditorWorkspaceSplit(
"nested-split",
UIEditorWorkspaceSplitAxis::Horizontal,
0.5f,
BuildUIEditorWorkspacePanel("panel-b-node", "panel-b", "Panel B", true),
BuildUIEditorWorkspacePanel("panel-c-node", "panel-c", "Panel C", true))
},
0u);
const auto result = ValidateUIEditorWorkspace(workspace);
EXPECT_EQ(result.code, UIEditorWorkspaceValidationCode::NonPanelTabChild);
}
TEST(UIEditorWorkspaceModelTest, VisiblePanelsOnlyIncludeSelectedTabsAcrossSplitTree) {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceSplit(
"root-split",
UIEditorWorkspaceSplitAxis::Horizontal,
0.68f,
BuildUIEditorWorkspacePanel("left-panel-node", "left-panel", "Left Panel", true),
BuildUIEditorWorkspaceSplit(
"right-split",
UIEditorWorkspaceSplitAxis::Vertical,
0.74f,
BuildUIEditorWorkspaceTabStack(
"document-tabs",
{
BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true),
BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true)
},
1u),
BuildUIEditorWorkspacePanel("bottom-panel-node", "bottom-panel", "Bottom Panel", true)));
workspace.activePanelId = "doc-b";
const auto validation = ValidateUIEditorWorkspace(workspace);
ASSERT_TRUE(validation.IsValid()) << validation.message;
const auto visibleIds = CollectVisiblePanelIds(workspace);
ASSERT_EQ(visibleIds.size(), 3u);
EXPECT_EQ(visibleIds[0], "left-panel");
EXPECT_EQ(visibleIds[1], "doc-b");
EXPECT_EQ(visibleIds[2], "bottom-panel");
const auto* activePanel = FindUIEditorWorkspaceActivePanel(workspace);
ASSERT_NE(activePanel, nullptr);
EXPECT_EQ(activePanel->panelId, "doc-b");
}
TEST(UIEditorWorkspaceModelTest, ActivatingHiddenPanelSelectsContainingTabAndUpdatesActivePanel) {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceSplit(
"root-split",
UIEditorWorkspaceSplitAxis::Horizontal,
0.62f,
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("details-node", "details", "Details", true));
ASSERT_TRUE(ContainsUIEditorWorkspacePanel(workspace, "doc-b"));
ASSERT_TRUE(TryActivateUIEditorWorkspacePanel(workspace, "doc-b"));
EXPECT_EQ(workspace.activePanelId, "doc-b");
ASSERT_EQ(workspace.root.children.front().selectedTabIndex, 1u);
const auto visibleIds = CollectVisiblePanelIds(workspace);
ASSERT_EQ(visibleIds.size(), 2u);
EXPECT_EQ(visibleIds[0], "doc-b");
EXPECT_EQ(visibleIds[1], "details");
}
TEST(UIEditorWorkspaceModelTest, ValidationRejectsActivePanelHiddenByCurrentTabSelection) {
UIEditorWorkspaceModel workspace = {};
workspace.root = BuildUIEditorWorkspaceTabStack(
"document-tabs",
{
BuildUIEditorWorkspacePanel("doc-a-node", "doc-a", "Document A", true),
BuildUIEditorWorkspacePanel("doc-b-node", "doc-b", "Document B", true)
},
0u);
workspace.activePanelId = "doc-b";
const auto result = ValidateUIEditorWorkspace(workspace);
EXPECT_EQ(result.code, UIEditorWorkspaceValidationCode::InvalidActivePanelId);
}

View File

@@ -1,12 +1,14 @@
set(RUNTIME_UI_TEST_SOURCES
${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_shortcut_scope.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_splitter_validation.cpp
${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_tab_strip.cpp
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_runtime.cpp
)
add_executable(runtime_ui_tests ${RUNTIME_UI_TEST_SOURCES})
if(MSVC)
target_compile_options(runtime_ui_tests PRIVATE /FS)
set_target_properties(runtime_ui_tests PROPERTIES
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
)

View File

@@ -0,0 +1,245 @@
#include <gtest/gtest.h>
#include <XCEngine/Input/InputTypes.h>
#include <XCEngine/UI/Runtime/UIScreenDocumentHost.h>
#include <XCEngine/UI/Runtime/UIScreenPlayer.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <string>
namespace {
using XCEngine::Input::KeyCode;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::UIDrawCommand;
using XCEngine::UI::UIDrawCommandType;
using XCEngine::UI::UIDrawData;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::Runtime::UIScreenAsset;
using XCEngine::UI::Runtime::UIScreenFrameInput;
using XCEngine::UI::Runtime::UIScreenPlayer;
using XCEngine::UI::Runtime::UIDocumentScreenHost;
namespace fs = std::filesystem;
class TempFileScope {
public:
TempFileScope(std::string stem, std::string extension, std::string contents) {
const auto uniqueId = std::to_string(
std::chrono::steady_clock::now().time_since_epoch().count());
m_path = fs::temp_directory_path() / (std::move(stem) + "_" + uniqueId + std::move(extension));
std::ofstream output(m_path, std::ios::binary | std::ios::trunc);
output << contents;
}
~TempFileScope() {
std::error_code ec;
fs::remove(m_path, ec);
}
const fs::path& Path() const {
return m_path;
}
private:
fs::path m_path = {};
};
UIScreenAsset BuildScreenAsset(const fs::path& viewPath, const char* screenId) {
UIScreenAsset screen = {};
screen.screenId = screenId;
screen.documentPath = viewPath.string();
return screen;
}
UIScreenFrameInput BuildInputState(std::uint64_t frameIndex) {
UIScreenFrameInput input = {};
input.viewportRect = UIRect(0.0f, 0.0f, 960.0f, 640.0f);
input.frameIndex = frameIndex;
input.focused = true;
return input;
}
std::string BuildTabStripMarkup() {
return
"<View name=\"Runtime TabStrip Test\">\n"
" <Column padding=\"18\" gap=\"10\">\n"
" <TabStrip id=\"workspace-tabs\" tabHeaderHeight=\"34\" tabMinWidth=\"84\">\n"
" <Tab id=\"tab-inspector\" label=\"Inspector\" selected=\"true\">\n"
" <Card title=\"Inspector Content\">\n"
" <Text text=\"Selected: Inspector\" />\n"
" </Card>\n"
" </Tab>\n"
" <Tab id=\"tab-console\" label=\"Console\">\n"
" <Card title=\"Console Content\">\n"
" <Text text=\"Selected: Console\" />\n"
" </Card>\n"
" </Tab>\n"
" <Tab id=\"tab-profiler\" label=\"Profiler\">\n"
" <Card title=\"Profiler Content\">\n"
" <Text text=\"Selected: Profiler\" />\n"
" </Card>\n"
" </Tab>\n"
" </TabStrip>\n"
" </Column>\n"
"</View>\n";
}
bool DrawDataContainsText(
const UIDrawData& drawData,
const std::string& text) {
for (const UIDrawList& drawList : drawData.GetDrawLists()) {
for (const UIDrawCommand& command : drawList.GetCommands()) {
if (command.type == UIDrawCommandType::Text && command.text == text) {
return true;
}
}
}
return false;
}
const UIDrawCommand* FindTextCommand(
const UIDrawData& drawData,
const std::string& text) {
for (const UIDrawList& drawList : drawData.GetDrawLists()) {
for (const UIDrawCommand& command : drawList.GetCommands()) {
if (command.type == UIDrawCommandType::Text && command.text == text) {
return &command;
}
}
}
return nullptr;
}
UIInputEvent MakePointerButtonEvent(
UIInputEventType type,
const UIPoint& position) {
UIInputEvent event = {};
event.type = type;
event.pointerButton = UIPointerButton::Left;
event.position = position;
return event;
}
UIInputEvent MakeKeyDownEvent(KeyCode keyCode) {
UIInputEvent event = {};
event.type = UIInputEventType::KeyDown;
event.keyCode = static_cast<std::int32_t>(keyCode);
return event;
}
} // namespace
TEST(UIRuntimeTabStripValidationTest, EmptyTabStripProducesExplicitFrameError) {
TempFileScope viewFile(
"xcui_runtime_invalid_tab_strip",
".xcui",
"<View name=\"Invalid TabStrip Test\">\n"
" <Column padding=\"16\" gap=\"10\">\n"
" <TabStrip id=\"broken-tabs\" />\n"
" </Column>\n"
"</View>\n");
UIDocumentScreenHost host = {};
UIScreenPlayer player(host);
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.invalid_tab_strip")));
const auto& frame = player.Update(BuildInputState(1u));
EXPECT_NE(frame.errorMessage.find("broken-tabs"), std::string::npos);
EXPECT_NE(frame.errorMessage.find("at least 1 Tab child"), std::string::npos);
}
TEST(UIRuntimeTabStripTest, PointerSelectingTabSwitchesVisibleContentAndPersists) {
TempFileScope viewFile("xcui_runtime_tab_strip_pointer", ".xcui", BuildTabStripMarkup());
UIDocumentScreenHost host = {};
UIScreenPlayer player(host);
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.tab_strip.pointer")));
const auto& initialFrame = player.Update(BuildInputState(1u));
EXPECT_TRUE(DrawDataContainsText(initialFrame.drawData, "Inspector Content"));
EXPECT_FALSE(DrawDataContainsText(initialFrame.drawData, "Console Content"));
const UIDrawCommand* consoleTab = FindTextCommand(initialFrame.drawData, "Console");
ASSERT_NE(consoleTab, nullptr);
UIScreenFrameInput selectInput = BuildInputState(2u);
selectInput.events.push_back(MakePointerButtonEvent(UIInputEventType::PointerButtonDown, consoleTab->position));
selectInput.events.push_back(MakePointerButtonEvent(UIInputEventType::PointerButtonUp, consoleTab->position));
const auto& selectedFrame = player.Update(selectInput);
EXPECT_FALSE(DrawDataContainsText(selectedFrame.drawData, "Inspector Content"));
EXPECT_TRUE(DrawDataContainsText(selectedFrame.drawData, "Console Content"));
const auto& debugAfterSelect = host.GetInputDebugSnapshot();
EXPECT_EQ(debugAfterSelect.lastResult, "Tab selected");
EXPECT_NE(debugAfterSelect.focusedStateKey.find("/workspace-tabs/tab-console"), std::string::npos);
const auto& persistedFrame = player.Update(BuildInputState(3u));
EXPECT_TRUE(DrawDataContainsText(persistedFrame.drawData, "Console Content"));
EXPECT_FALSE(DrawDataContainsText(persistedFrame.drawData, "Inspector Content"));
}
TEST(UIRuntimeTabStripTest, KeyboardNavigationUpdatesSelectionAndFocus) {
TempFileScope viewFile("xcui_runtime_tab_strip_keyboard", ".xcui", BuildTabStripMarkup());
UIDocumentScreenHost host = {};
UIScreenPlayer player(host);
ASSERT_TRUE(player.Load(BuildScreenAsset(viewFile.Path(), "runtime.tab_strip.keyboard")));
const auto& initialFrame = player.Update(BuildInputState(1u));
const UIDrawCommand* inspectorTab = FindTextCommand(initialFrame.drawData, "Inspector");
ASSERT_NE(inspectorTab, nullptr);
UIScreenFrameInput focusInput = BuildInputState(2u);
focusInput.events.push_back(MakePointerButtonEvent(UIInputEventType::PointerButtonDown, inspectorTab->position));
focusInput.events.push_back(MakePointerButtonEvent(UIInputEventType::PointerButtonUp, inspectorTab->position));
player.Update(focusInput);
UIScreenFrameInput rightInput = BuildInputState(3u);
rightInput.events.push_back(MakeKeyDownEvent(KeyCode::Right));
const auto& consoleFrame = player.Update(rightInput);
EXPECT_TRUE(DrawDataContainsText(consoleFrame.drawData, "Console Content"));
EXPECT_FALSE(DrawDataContainsText(consoleFrame.drawData, "Inspector Content"));
const auto& afterRight = host.GetInputDebugSnapshot();
EXPECT_EQ(afterRight.lastResult, "Tab navigated");
EXPECT_NE(afterRight.focusedStateKey.find("/workspace-tabs/tab-console"), std::string::npos);
UIScreenFrameInput leftInput = BuildInputState(4u);
leftInput.events.push_back(MakeKeyDownEvent(KeyCode::Left));
const auto& inspectorFrameAfterLeft = player.Update(leftInput);
EXPECT_TRUE(DrawDataContainsText(inspectorFrameAfterLeft.drawData, "Inspector Content"));
EXPECT_FALSE(DrawDataContainsText(inspectorFrameAfterLeft.drawData, "Console Content"));
const auto& afterLeft = host.GetInputDebugSnapshot();
EXPECT_EQ(afterLeft.lastResult, "Tab navigated");
EXPECT_NE(afterLeft.focusedStateKey.find("/workspace-tabs/tab-inspector"), std::string::npos);
UIScreenFrameInput endInput = BuildInputState(5u);
endInput.events.push_back(MakeKeyDownEvent(KeyCode::End));
const auto& profilerFrame = player.Update(endInput);
EXPECT_TRUE(DrawDataContainsText(profilerFrame.drawData, "Profiler Content"));
EXPECT_FALSE(DrawDataContainsText(profilerFrame.drawData, "Inspector Content"));
const auto& afterEnd = host.GetInputDebugSnapshot();
EXPECT_EQ(afterEnd.lastResult, "Tab navigated");
EXPECT_NE(afterEnd.focusedStateKey.find("/workspace-tabs/tab-profiler"), std::string::npos);
UIScreenFrameInput homeInput = BuildInputState(6u);
homeInput.events.push_back(MakeKeyDownEvent(KeyCode::Home));
const auto& inspectorFrame = player.Update(homeInput);
EXPECT_TRUE(DrawDataContainsText(inspectorFrame.drawData, "Inspector Content"));
EXPECT_FALSE(DrawDataContainsText(inspectorFrame.drawData, "Profiler Content"));
const auto& afterHome = host.GetInputDebugSnapshot();
EXPECT_EQ(afterHome.lastResult, "Tab navigated");
EXPECT_NE(afterHome.focusedStateKey.find("/workspace-tabs/tab-inspector"), std::string::npos);
}

View File

@@ -121,6 +121,8 @@ Runtime 的集成测试结构与 Editor 保持同一规范,但宿主职责必
- `editor.input.pointer_states`
- `editor.input.shortcut_scope`
- `editor.layout.splitter_resize`
- `editor.layout.tab_strip_selection`
- `editor.layout.workspace_compose`
这些场景只用于验证 XCUI 模块能力,不代表开始复刻完整 editor 面板。