feat(xcui): add tab strip and workspace compose foundations
This commit is contained in:
133
engine/include/XCEngine/UI/Layout/UITabStripLayout.h
Normal file
133
engine/include/XCEngine/UI/Layout/UITabStripLayout.h
Normal 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
|
||||
@@ -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 = {};
|
||||
|
||||
110
engine/include/XCEngine/UI/Widgets/UITabStripModel.h
Normal file
110
engine/include/XCEngine/UI/Widgets/UITabStripModel.h
Normal 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
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
108
new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h
Normal file
108
new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h
Normal 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
|
||||
@@ -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 =
|
||||
|
||||
298
new_editor/src/editor/UIEditorWorkspaceModel.cpp
Normal file
298
new_editor/src/editor/UIEditorWorkspaceModel.cpp
Normal 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
|
||||
@@ -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
|
||||
|
||||
72
tests/UI/Core/unit/test_ui_tab_strip_layout.cpp
Normal file
72
tests/UI/Core/unit/test_ui_tab_strip_layout.cpp
Normal 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);
|
||||
}
|
||||
53
tests/UI/Core/unit/test_ui_tab_strip_model.cpp
Normal file
53
tests/UI/Core/unit/test_ui_tab_strip_model.cpp
Normal 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));
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 / End:selected tab 应变化。" />
|
||||
<Text text="3. 右下角 Result 正常应显示 Tab selected 或 Tab navigated;Focused 应落在当前 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>
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
143
tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp
Normal file
143
tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp
Normal 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);
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
245
tests/UI/Runtime/unit/test_ui_runtime_tab_strip.cpp
Normal file
245
tests/UI/Runtime/unit/test_ui_runtime_tab_strip.cpp
Normal 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);
|
||||
}
|
||||
@@ -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 面板。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user