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/Runtime/UIScreenTypes.h>
|
||||||
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
|
#include <XCEngine/UI/Widgets/UISplitterInteraction.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
@@ -91,6 +92,7 @@ private:
|
|||||||
UIInputDispatcher m_inputDispatcher;
|
UIInputDispatcher m_inputDispatcher;
|
||||||
std::unordered_map<std::string, float> m_verticalScrollOffsets = {};
|
std::unordered_map<std::string, float> m_verticalScrollOffsets = {};
|
||||||
std::unordered_map<std::string, float> m_splitterRatios = {};
|
std::unordered_map<std::string, float> m_splitterRatios = {};
|
||||||
|
std::unordered_map<std::string, std::size_t> m_tabStripSelectedIndices = {};
|
||||||
PointerState m_pointerState = {};
|
PointerState m_pointerState = {};
|
||||||
SplitterDragRuntimeState m_splitterDragState = {};
|
SplitterDragRuntimeState m_splitterDragState = {};
|
||||||
InputDebugSnapshot m_inputDebugSnapshot = {};
|
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/Core/Math/Color.h>
|
||||||
#include <XCEngine/Resources/UI/UIDocumentCompiler.h>
|
#include <XCEngine/Resources/UI/UIDocumentCompiler.h>
|
||||||
#include <XCEngine/UI/Layout/LayoutEngine.h>
|
#include <XCEngine/UI/Layout/LayoutEngine.h>
|
||||||
|
#include <XCEngine/UI/Layout/UITabStripLayout.h>
|
||||||
|
#include <XCEngine/UI/Widgets/UITabStripModel.h>
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
@@ -36,6 +38,7 @@ namespace Layout = XCEngine::UI::Layout;
|
|||||||
constexpr float kDefaultFontSize = 16.0f;
|
constexpr float kDefaultFontSize = 16.0f;
|
||||||
constexpr float kSmallFontSize = 13.0f;
|
constexpr float kSmallFontSize = 13.0f;
|
||||||
constexpr float kButtonFontSize = 14.0f;
|
constexpr float kButtonFontSize = 14.0f;
|
||||||
|
constexpr float kTabFontSize = 13.0f;
|
||||||
constexpr float kApproximateGlyphWidth = 0.56f;
|
constexpr float kApproximateGlyphWidth = 0.56f;
|
||||||
constexpr float kHeaderTextInset = 12.0f;
|
constexpr float kHeaderTextInset = 12.0f;
|
||||||
constexpr float kHeaderTextGap = 2.0f;
|
constexpr float kHeaderTextGap = 2.0f;
|
||||||
@@ -57,6 +60,8 @@ struct RuntimeLayoutNode {
|
|||||||
bool wantsPointerCapture = false;
|
bool wantsPointerCapture = false;
|
||||||
bool isScrollView = false;
|
bool isScrollView = false;
|
||||||
bool isSplitter = false;
|
bool isSplitter = false;
|
||||||
|
bool isTabStrip = false;
|
||||||
|
bool isTab = false;
|
||||||
bool textInput = false;
|
bool textInput = false;
|
||||||
Layout::UILayoutAxis splitterAxis = Layout::UILayoutAxis::Horizontal;
|
Layout::UILayoutAxis splitterAxis = Layout::UILayoutAxis::Horizontal;
|
||||||
Layout::UISplitterMetrics splitterMetrics = {};
|
Layout::UISplitterMetrics splitterMetrics = {};
|
||||||
@@ -64,6 +69,13 @@ struct RuntimeLayoutNode {
|
|||||||
float splitterRatio = 0.5f;
|
float splitterRatio = 0.5f;
|
||||||
UIRect splitterHandleRect = {};
|
UIRect splitterHandleRect = {};
|
||||||
UIRect splitterHandleHitRect = {};
|
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;
|
bool hasShortcutBinding = false;
|
||||||
UIShortcutBinding shortcutBinding = {};
|
UIShortcutBinding shortcutBinding = {};
|
||||||
enum class ShortcutScopeRoot : std::uint8_t {
|
enum class ShortcutScopeRoot : std::uint8_t {
|
||||||
@@ -175,6 +187,8 @@ std::string GetAttribute(
|
|||||||
return attribute != nullptr ? ToStdString(attribute->value) : fallback;
|
return attribute != nullptr ? ToStdString(attribute->value) : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string ResolveNodeText(const UIDocumentNode& node);
|
||||||
|
|
||||||
bool ParseBoolAttribute(
|
bool ParseBoolAttribute(
|
||||||
const UIDocumentNode& node,
|
const UIDocumentNode& node,
|
||||||
const char* name,
|
const char* name,
|
||||||
@@ -446,6 +460,42 @@ float MeasureHeaderHeight(const UIDocumentNode& node) {
|
|||||||
return headerHeight;
|
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) {
|
bool TryParseFloat(const std::string& text, float& outValue) {
|
||||||
if (text.empty()) {
|
if (text.empty()) {
|
||||||
return false;
|
return false;
|
||||||
@@ -468,6 +518,28 @@ bool TryParseFloat(const std::string& text, float& outValue) {
|
|||||||
return true;
|
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(
|
float ParseFloatAttribute(
|
||||||
const UIDocumentNode& node,
|
const UIDocumentNode& node,
|
||||||
const char* name,
|
const char* name,
|
||||||
@@ -577,6 +649,47 @@ std::string ResolveNodeText(const UIDocumentNode& node) {
|
|||||||
return ToStdString(node.tagName);
|
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) {
|
bool IsHorizontalTag(const std::string& tagName) {
|
||||||
return tagName == "Row";
|
return tagName == "Row";
|
||||||
}
|
}
|
||||||
@@ -589,6 +702,14 @@ bool IsSplitterTag(const std::string& tagName) {
|
|||||||
return tagName == "Splitter";
|
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) {
|
bool IsButtonTag(const std::string& tagName) {
|
||||||
return tagName == "Button";
|
return tagName == "Button";
|
||||||
}
|
}
|
||||||
@@ -625,6 +746,27 @@ Layout::UISplitterMetrics ParseSplitterMetrics(const UIDocumentNode& node) {
|
|||||||
return metrics;
|
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) {
|
bool IsContainerTag(const UIDocumentNode& node) {
|
||||||
if (node.children.Size() > 0u) {
|
if (node.children.Size() > 0u) {
|
||||||
return true;
|
return true;
|
||||||
@@ -635,6 +777,8 @@ bool IsContainerTag(const UIDocumentNode& node) {
|
|||||||
tagName == "Column" ||
|
tagName == "Column" ||
|
||||||
tagName == "Row" ||
|
tagName == "Row" ||
|
||||||
tagName == "Splitter" ||
|
tagName == "Splitter" ||
|
||||||
|
tagName == "TabStrip" ||
|
||||||
|
tagName == "Tab" ||
|
||||||
tagName == "ScrollView" ||
|
tagName == "ScrollView" ||
|
||||||
tagName == "Card" ||
|
tagName == "Card" ||
|
||||||
tagName == "Button";
|
tagName == "Button";
|
||||||
@@ -645,12 +789,12 @@ bool IsPointerInteractiveNode(const UIDocumentNode& node) {
|
|||||||
return ParseBoolAttribute(
|
return ParseBoolAttribute(
|
||||||
node,
|
node,
|
||||||
"interactive",
|
"interactive",
|
||||||
IsButtonTag(tagName) || IsScrollViewTag(tagName) || IsSplitterTag(tagName));
|
IsButtonTag(tagName) || IsScrollViewTag(tagName) || IsSplitterTag(tagName) || IsTabTag(tagName));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IsFocusableNode(const UIDocumentNode& node) {
|
bool IsFocusableNode(const UIDocumentNode& node) {
|
||||||
const std::string tagName = ToStdString(node.tagName);
|
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) {
|
bool WantsPointerCapture(const UIDocumentNode& node) {
|
||||||
@@ -713,11 +857,15 @@ const UIRect& GetNodeInteractionRect(const RuntimeLayoutNode& node) {
|
|||||||
return node.splitterHandleHitRect;
|
return node.splitterHandleHitRect;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.isTab) {
|
||||||
|
return node.tabHeaderRect;
|
||||||
|
}
|
||||||
|
|
||||||
return node.isScrollView ? node.scrollViewportRect : node.rect;
|
return node.isScrollView ? node.scrollViewportRect : node.rect;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IsNodeTargetable(const RuntimeLayoutNode& node) {
|
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(
|
const RuntimeLayoutNode* FindNodeByElementId(
|
||||||
@@ -752,6 +900,37 @@ RuntimeLayoutNode* FindNodeByElementId(
|
|||||||
return nullptr;
|
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(
|
bool CollectNodesForInputPath(
|
||||||
const RuntimeLayoutNode& node,
|
const RuntimeLayoutNode& node,
|
||||||
const UIInputPath& path,
|
const UIInputPath& path,
|
||||||
@@ -766,6 +945,11 @@ bool CollectNodesForInputPath(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!AreTabChildrenVisible(node)) {
|
||||||
|
outNodes.pop_back();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
for (const RuntimeLayoutNode& child : node.children) {
|
for (const RuntimeLayoutNode& child : node.children) {
|
||||||
if (CollectNodesForInputPath(child, path, index + 1u, outNodes)) {
|
if (CollectNodesForInputPath(child, path, index + 1u, outNodes)) {
|
||||||
return true;
|
return true;
|
||||||
@@ -793,7 +977,7 @@ std::string ResolveStateKeyForElementId(
|
|||||||
bool PathTargetExists(
|
bool PathTargetExists(
|
||||||
const RuntimeLayoutNode& root,
|
const RuntimeLayoutNode& root,
|
||||||
const UIInputPath& path) {
|
const UIInputPath& path) {
|
||||||
return !path.Empty() && FindNodeByElementId(root, path.Target()) != nullptr;
|
return !path.Empty() && FindVisibleNodeByElementId(root, path.Target()) != nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SplitterTargetExists(
|
bool SplitterTargetExists(
|
||||||
@@ -843,7 +1027,26 @@ std::string ValidateRuntimeLayoutTree(const RuntimeLayoutNode& node) {
|
|||||||
return "Splitter '" + splitterName + "' must contain exactly 2 child elements.";
|
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) {
|
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);
|
const std::string error = ValidateRuntimeLayoutTree(child);
|
||||||
if (!error.empty()) {
|
if (!error.empty()) {
|
||||||
return error;
|
return error;
|
||||||
@@ -923,6 +1126,10 @@ void RegisterShortcutBindings(
|
|||||||
registry.RegisterBinding(node.shortcutBinding);
|
registry.RegisterBinding(node.shortcutBinding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!AreTabChildrenVisible(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const RuntimeLayoutNode& child : node.children) {
|
for (const RuntimeLayoutNode& child : node.children) {
|
||||||
RegisterShortcutBindings(child, registry);
|
RegisterShortcutBindings(child, registry);
|
||||||
}
|
}
|
||||||
@@ -964,6 +1171,10 @@ void CollectFocusablePaths(
|
|||||||
outPaths.push_back(node.inputPath);
|
outPaths.push_back(node.inputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!AreTabChildrenVisible(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const RuntimeLayoutNode& child : node.children) {
|
for (const RuntimeLayoutNode& child : node.children) {
|
||||||
CollectFocusablePaths(child, outPaths);
|
CollectFocusablePaths(child, outPaths);
|
||||||
}
|
}
|
||||||
@@ -1036,9 +1247,11 @@ const RuntimeLayoutNode* FindDeepestInputTarget(
|
|||||||
return &node;
|
return &node;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const RuntimeLayoutNode& child : node.children) {
|
if (AreTabChildrenVisible(node)) {
|
||||||
if (const RuntimeLayoutNode* found = FindDeepestInputTarget(child, point, &nextClip); found != nullptr) {
|
for (const RuntimeLayoutNode& child : node.children) {
|
||||||
return found;
|
if (const RuntimeLayoutNode* found = FindDeepestInputTarget(child, point, &nextClip); found != nullptr) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1149,8 +1362,10 @@ RuntimeLayoutNode BuildLayoutTree(
|
|||||||
const UIDocumentNode& source,
|
const UIDocumentNode& source,
|
||||||
const std::string& parentStateKey,
|
const std::string& parentStateKey,
|
||||||
const UIInputPath& parentInputPath,
|
const UIInputPath& parentInputPath,
|
||||||
std::size_t siblingIndex) {
|
std::size_t siblingIndex,
|
||||||
|
const std::unordered_map<std::string, std::size_t>& tabStripSelectedIndices) {
|
||||||
RuntimeLayoutNode node = {};
|
RuntimeLayoutNode node = {};
|
||||||
|
const std::string tagName = ToStdString(source.tagName);
|
||||||
node.source = &source;
|
node.source = &source;
|
||||||
node.stateKey = parentStateKey + "/" + BuildNodeStateKeySegment(source, siblingIndex);
|
node.stateKey = parentStateKey + "/" + BuildNodeStateKeySegment(source, siblingIndex);
|
||||||
node.elementId = HashStateKeyToElementId(node.stateKey);
|
node.elementId = HashStateKeyToElementId(node.stateKey);
|
||||||
@@ -1159,11 +1374,14 @@ RuntimeLayoutNode BuildLayoutTree(
|
|||||||
node.pointerInteractive = IsPointerInteractiveNode(source);
|
node.pointerInteractive = IsPointerInteractiveNode(source);
|
||||||
node.focusable = ParseBoolAttribute(source, "focusable", IsFocusableNode(source));
|
node.focusable = ParseBoolAttribute(source, "focusable", IsFocusableNode(source));
|
||||||
node.wantsPointerCapture = WantsPointerCapture(source);
|
node.wantsPointerCapture = WantsPointerCapture(source);
|
||||||
node.isScrollView = IsScrollViewTag(ToStdString(source.tagName));
|
node.isScrollView = IsScrollViewTag(tagName);
|
||||||
node.isSplitter = IsSplitterTag(ToStdString(source.tagName));
|
node.isSplitter = IsSplitterTag(tagName);
|
||||||
|
node.isTabStrip = IsTabStripTag(tagName);
|
||||||
|
node.isTab = IsTabTag(tagName);
|
||||||
node.textInput = IsTextInputNode(source);
|
node.textInput = IsTextInputNode(source);
|
||||||
node.splitterAxis = ParseAxisAttribute(source, "axis", Layout::UILayoutAxis::Horizontal);
|
node.splitterAxis = ParseAxisAttribute(source, "axis", Layout::UILayoutAxis::Horizontal);
|
||||||
node.splitterMetrics = ParseSplitterMetrics(source);
|
node.splitterMetrics = ParseSplitterMetrics(source);
|
||||||
|
node.tabStripMetrics = ParseTabStripMetrics(source);
|
||||||
node.splitterRatio = ParseRatioAttribute(
|
node.splitterRatio = ParseRatioAttribute(
|
||||||
source,
|
source,
|
||||||
"splitRatio",
|
"splitRatio",
|
||||||
@@ -1172,8 +1390,24 @@ RuntimeLayoutNode BuildLayoutTree(
|
|||||||
node.hasShortcutBinding = TryBuildShortcutBinding(source, node.elementId, node.shortcutBinding);
|
node.hasShortcutBinding = TryBuildShortcutBinding(source, node.elementId, node.shortcutBinding);
|
||||||
node.children.reserve(source.children.Size());
|
node.children.reserve(source.children.Size());
|
||||||
for (std::size_t index = 0; index < source.children.Size(); ++index) {
|
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;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1260,6 +1494,40 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
|
|||||||
return node.desiredSize;
|
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 = {};
|
Layout::UIStackLayoutOptions options = {};
|
||||||
options.axis = IsHorizontalTag(tagName)
|
options.axis = IsHorizontalTag(tagName)
|
||||||
? Layout::UILayoutAxis::Horizontal
|
? Layout::UILayoutAxis::Horizontal
|
||||||
@@ -1288,20 +1556,22 @@ UISize MeasureNode(RuntimeLayoutNode& node) {
|
|||||||
node.desiredSize = measured.desiredSize;
|
node.desiredSize = measured.desiredSize;
|
||||||
node.minimumSize = minimumMeasured.desiredSize;
|
node.minimumSize = minimumMeasured.desiredSize;
|
||||||
|
|
||||||
const float headerHeight = MeasureHeaderHeight(source);
|
const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source);
|
||||||
const float headerTextWidth = MeasureHeaderTextWidth(source);
|
const float headerTextWidth = node.isTab ? 0.0f : MeasureHeaderTextWidth(source);
|
||||||
|
|
||||||
node.desiredSize.width = (std::max)(
|
if (!node.isTab) {
|
||||||
node.desiredSize.width,
|
node.desiredSize.width = (std::max)(
|
||||||
headerTextWidth > 0.0f
|
node.desiredSize.width,
|
||||||
? headerTextWidth + options.padding.Horizontal()
|
headerTextWidth > 0.0f
|
||||||
: MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal());
|
? headerTextWidth + options.padding.Horizontal()
|
||||||
|
: MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal());
|
||||||
|
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.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.minimumSize.height += headerHeight;
|
node.minimumSize.height += headerHeight;
|
||||||
|
|
||||||
if (node.isScrollView) {
|
if (node.isScrollView) {
|
||||||
@@ -1331,6 +1601,9 @@ void ArrangeNode(
|
|||||||
node.scrollOffsetY = 0.0f;
|
node.scrollOffsetY = 0.0f;
|
||||||
node.splitterHandleRect = {};
|
node.splitterHandleRect = {};
|
||||||
node.splitterHandleHitRect = {};
|
node.splitterHandleHitRect = {};
|
||||||
|
node.tabStripHeaderRect = {};
|
||||||
|
node.tabStripContentRect = {};
|
||||||
|
node.tabHeaderRect = {};
|
||||||
|
|
||||||
const UIDocumentNode& source = *node.source;
|
const UIDocumentNode& source = *node.source;
|
||||||
if (!IsContainerTag(source)) {
|
if (!IsContainerTag(source)) {
|
||||||
@@ -1367,6 +1640,37 @@ void ArrangeNode(
|
|||||||
return;
|
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);
|
const std::string tagName = ToStdString(source.tagName);
|
||||||
Layout::UIStackLayoutOptions options = {};
|
Layout::UIStackLayoutOptions options = {};
|
||||||
options.axis = IsHorizontalTag(tagName)
|
options.axis = IsHorizontalTag(tagName)
|
||||||
@@ -1380,7 +1684,7 @@ void ArrangeNode(
|
|||||||
source,
|
source,
|
||||||
tagName == "View" ? 16.0f : 12.0f);
|
tagName == "View" ? 16.0f : 12.0f);
|
||||||
|
|
||||||
const float headerHeight = MeasureHeaderHeight(source);
|
const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source);
|
||||||
|
|
||||||
UIRect contentRect = rect;
|
UIRect contentRect = rect;
|
||||||
contentRect.y += headerHeight;
|
contentRect.y += headerHeight;
|
||||||
@@ -1428,9 +1732,11 @@ void ArrangeNode(
|
|||||||
RuntimeLayoutNode* FindDeepestScrollTarget(
|
RuntimeLayoutNode* FindDeepestScrollTarget(
|
||||||
RuntimeLayoutNode& node,
|
RuntimeLayoutNode& node,
|
||||||
const UIPoint& point) {
|
const UIPoint& point) {
|
||||||
for (RuntimeLayoutNode& child : node.children) {
|
if (AreTabChildrenVisible(node)) {
|
||||||
if (RuntimeLayoutNode* target = FindDeepestScrollTarget(child, point); target != nullptr) {
|
for (RuntimeLayoutNode& child : node.children) {
|
||||||
return target;
|
if (RuntimeLayoutNode* target = FindDeepestScrollTarget(child, point); target != nullptr) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1452,9 +1758,11 @@ RuntimeLayoutNode* FindDeepestScrollTarget(
|
|||||||
RuntimeLayoutNode* FindDeepestHoveredScrollView(
|
RuntimeLayoutNode* FindDeepestHoveredScrollView(
|
||||||
RuntimeLayoutNode& node,
|
RuntimeLayoutNode& node,
|
||||||
const UIPoint& point) {
|
const UIPoint& point) {
|
||||||
for (RuntimeLayoutNode& child : node.children) {
|
if (AreTabChildrenVisible(node)) {
|
||||||
if (RuntimeLayoutNode* hovered = FindDeepestHoveredScrollView(child, point); hovered != nullptr) {
|
for (RuntimeLayoutNode& child : node.children) {
|
||||||
return hovered;
|
if (RuntimeLayoutNode* hovered = FindDeepestHoveredScrollView(child, point); hovered != nullptr) {
|
||||||
|
return hovered;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,9 +1780,11 @@ const RuntimeLayoutNode* FindFirstScrollView(const RuntimeLayoutNode& node) {
|
|||||||
return &node;
|
return &node;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const RuntimeLayoutNode& child : node.children) {
|
if (AreTabChildrenVisible(node)) {
|
||||||
if (const RuntimeLayoutNode* found = FindFirstScrollView(child); found != nullptr) {
|
for (const RuntimeLayoutNode& child : node.children) {
|
||||||
return found;
|
if (const RuntimeLayoutNode* found = FindFirstScrollView(child); found != nullptr) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1672,12 +1982,153 @@ void UpdateInputDebugSnapshot(
|
|||||||
shortcutContext.commandScope.widgetId);
|
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(
|
bool DispatchInputEvent(
|
||||||
RuntimeLayoutNode& root,
|
RuntimeLayoutNode& root,
|
||||||
const UIInputEvent& event,
|
const UIInputEvent& event,
|
||||||
const UIInputPath& hoveredPath,
|
const UIInputPath& hoveredPath,
|
||||||
UIInputDispatcher& inputDispatcher,
|
UIInputDispatcher& inputDispatcher,
|
||||||
std::unordered_map<std::string, float>& splitterRatios,
|
std::unordered_map<std::string, float>& splitterRatios,
|
||||||
|
std::unordered_map<std::string, std::size_t>& tabStripSelectedIndices,
|
||||||
UIDocumentScreenHost::SplitterDragRuntimeState& splitterDragState,
|
UIDocumentScreenHost::SplitterDragRuntimeState& splitterDragState,
|
||||||
UIDocumentScreenHost::InputDebugSnapshot& inputDebugSnapshot) {
|
UIDocumentScreenHost::InputDebugSnapshot& inputDebugSnapshot) {
|
||||||
if (event.type == UIInputEventType::PointerWheel) {
|
if (event.type == UIInputEventType::PointerWheel) {
|
||||||
@@ -1725,9 +2176,22 @@ bool DispatchInputEvent(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool focusedTabNavigationChanged = false;
|
||||||
|
if (TryHandleFocusedTabStripSelectionCommand(
|
||||||
|
root,
|
||||||
|
event,
|
||||||
|
inputDispatcher,
|
||||||
|
tabStripSelectedIndices,
|
||||||
|
inputDebugSnapshot,
|
||||||
|
focusedTabNavigationChanged)) {
|
||||||
|
return focusedTabNavigationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
bool pointerCaptureStarted = false;
|
bool pointerCaptureStarted = false;
|
||||||
bool layoutChanged = false;
|
bool layoutChanged = false;
|
||||||
bool splitterDragFinished = false;
|
bool splitterDragFinished = false;
|
||||||
|
bool tabSelectionTriggered = false;
|
||||||
|
bool tabSelectionChanged = false;
|
||||||
const UIInputDispatchSummary summary = inputDispatcher.Dispatch(
|
const UIInputDispatchSummary summary = inputDispatcher.Dispatch(
|
||||||
event,
|
event,
|
||||||
hoveredPath,
|
hoveredPath,
|
||||||
@@ -1736,7 +2200,7 @@ bool DispatchInputEvent(
|
|||||||
return UIInputDispatchDecision{};
|
return UIInputDispatchDecision{};
|
||||||
}
|
}
|
||||||
|
|
||||||
const RuntimeLayoutNode* node = FindNodeByElementId(root, request.elementId);
|
RuntimeLayoutNode* node = FindNodeByElementId(root, request.elementId);
|
||||||
if (node == nullptr) {
|
if (node == nullptr) {
|
||||||
return UIInputDispatchDecision{};
|
return UIInputDispatchDecision{};
|
||||||
}
|
}
|
||||||
@@ -1811,6 +2275,25 @@ bool DispatchInputEvent(
|
|||||||
return UIInputDispatchDecision{ true, false };
|
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 &&
|
if (event.type == UIInputEventType::PointerButtonDown &&
|
||||||
event.pointerButton == UIPointerButton::Left &&
|
event.pointerButton == UIPointerButton::Left &&
|
||||||
node->wantsPointerCapture) {
|
node->wantsPointerCapture) {
|
||||||
@@ -1846,6 +2329,10 @@ bool DispatchInputEvent(
|
|||||||
inputDebugSnapshot.lastResult = "Shortcut suppressed by text input";
|
inputDebugSnapshot.lastResult = "Shortcut suppressed by text input";
|
||||||
} else if (focusTraversalSuppressed) {
|
} else if (focusTraversalSuppressed) {
|
||||||
inputDebugSnapshot.lastResult = "Focus traversal suppressed by text input";
|
inputDebugSnapshot.lastResult = "Focus traversal suppressed by text input";
|
||||||
|
} else if (tabSelectionTriggered) {
|
||||||
|
inputDebugSnapshot.lastResult = tabSelectionChanged
|
||||||
|
? "Tab selected"
|
||||||
|
: "Tab selection unchanged";
|
||||||
} else if (pointerCaptureStarted) {
|
} else if (pointerCaptureStarted) {
|
||||||
inputDebugSnapshot.lastResult = splitterDragState.drag.active
|
inputDebugSnapshot.lastResult = splitterDragState.drag.active
|
||||||
? "Splitter drag started"
|
? "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(
|
void EmitNode(
|
||||||
const RuntimeLayoutNode& node,
|
const RuntimeLayoutNode& node,
|
||||||
const UIInputPath& hoveredPath,
|
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)) {
|
if (node.isSplitter && HasPositiveArea(node.splitterHandleRect)) {
|
||||||
const Color splitterColor =
|
const Color splitterColor =
|
||||||
visualState.capture || visualState.active
|
visualState.capture || visualState.active
|
||||||
@@ -1948,8 +2516,8 @@ void EmitNode(
|
|||||||
++stats.filledRectCommandCount;
|
++stats.filledRectCommandCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string title = GetAttribute(source, "title");
|
const std::string title = (node.isTab || node.isTabStrip) ? std::string() : GetAttribute(source, "title");
|
||||||
const std::string subtitle = GetAttribute(source, "subtitle");
|
const std::string subtitle = (node.isTab || node.isTabStrip) ? std::string() : GetAttribute(source, "subtitle");
|
||||||
float textY = node.rect.y + kHeaderTextInset;
|
float textY = node.rect.y + kHeaderTextInset;
|
||||||
if (!title.empty()) {
|
if (!title.empty()) {
|
||||||
drawList.AddText(
|
drawList.AddText(
|
||||||
@@ -1999,6 +2567,10 @@ void EmitNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const RuntimeLayoutNode& child : node.children) {
|
for (const RuntimeLayoutNode& child : node.children) {
|
||||||
|
if (child.isTab && !child.tabSelected) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
EmitNode(child, hoveredPath, focusController, drawList, stats);
|
EmitNode(child, hoveredPath, focusController, drawList, stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2119,7 +2691,12 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
|
|||||||
const std::string stateRoot = document.sourcePath.empty()
|
const std::string stateRoot = document.sourcePath.empty()
|
||||||
? document.displayName
|
? document.displayName
|
||||||
: document.sourcePath;
|
: 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);
|
result.errorMessage = ValidateRuntimeLayoutTree(root);
|
||||||
if (!result.errorMessage.empty()) {
|
if (!result.errorMessage.empty()) {
|
||||||
return result;
|
return result;
|
||||||
@@ -2191,6 +2768,7 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
|
|||||||
eventHoveredPath,
|
eventHoveredPath,
|
||||||
m_inputDispatcher,
|
m_inputDispatcher,
|
||||||
m_splitterRatios,
|
m_splitterRatios,
|
||||||
|
m_tabStripSelectedIndices,
|
||||||
m_splitterDragState,
|
m_splitterDragState,
|
||||||
m_inputDebugSnapshot)) {
|
m_inputDebugSnapshot)) {
|
||||||
ArrangeNode(root, viewportRect, m_verticalScrollOffsets, m_splitterRatios);
|
ArrangeNode(root, viewportRect, m_verticalScrollOffsets, m_splitterRatios);
|
||||||
@@ -2219,6 +2797,8 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame(
|
|||||||
SyncScrollOffsets(root, m_verticalScrollOffsets);
|
SyncScrollOffsets(root, m_verticalScrollOffsets);
|
||||||
m_splitterRatios.clear();
|
m_splitterRatios.clear();
|
||||||
SyncSplitterRatios(root, m_splitterRatios);
|
SyncSplitterRatios(root, m_splitterRatios);
|
||||||
|
m_tabStripSelectedIndices.clear();
|
||||||
|
SyncTabStripSelectedIndices(root, m_tabStripSelectedIndices);
|
||||||
|
|
||||||
const UIFocusController& focusController = m_inputDispatcher.GetFocusController();
|
const UIFocusController& focusController = m_inputDispatcher.GetFocusController();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ set(NEW_EDITOR_RESOURCE_FILES
|
|||||||
|
|
||||||
add_library(XCNewEditorLib STATIC
|
add_library(XCNewEditorLib STATIC
|
||||||
src/editor/EditorShellAsset.cpp
|
src/editor/EditorShellAsset.cpp
|
||||||
|
src/editor/UIEditorWorkspaceModel.cpp
|
||||||
src/Widgets/UIEditorCollectionPrimitives.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::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);
|
std::filesystem::create_directories(m_historyRoot, errorCode);
|
||||||
if (errorCode) {
|
if (errorCode) {
|
||||||
m_lastCaptureError = "Failed to create screenshot directory: " + m_historyRoot.string();
|
m_lastCaptureError = "Failed to create screenshot directory: " + m_historyRoot.string();
|
||||||
@@ -52,14 +60,26 @@ void AutoScreenshotController::CaptureIfRequested(
|
|||||||
|
|
||||||
std::string captureError = {};
|
std::string captureError = {};
|
||||||
const std::filesystem::path historyPath = BuildHistoryCapturePath(m_pendingReason);
|
const std::filesystem::path historyPath = BuildHistoryCapturePath(m_pendingReason);
|
||||||
if (!renderer.CaptureToPng(drawData, width, height, m_latestCapturePath, captureError) ||
|
if (!renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) {
|
||||||
!renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) {
|
|
||||||
m_lastCaptureError = std::move(captureError);
|
m_lastCaptureError = std::move(captureError);
|
||||||
m_lastCaptureSummary = "AutoShot failed";
|
m_lastCaptureSummary = "AutoShot failed";
|
||||||
m_capturePending = false;
|
m_capturePending = false;
|
||||||
return;
|
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_captureCount;
|
||||||
m_lastCaptureError.clear();
|
m_lastCaptureError.clear();
|
||||||
m_lastCaptureSummary =
|
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_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_layout.cpp
|
||||||
${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_interaction.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
|
# Migration bridge: legacy XCUI unit coverage still lives under tests/Core/UI
|
||||||
# until it is moved into tests/UI/Core/unit without changing behavior.
|
# until it is moved into tests/UI/Core/unit without changing behavior.
|
||||||
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_core.cpp
|
${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(splitter_resize)
|
||||||
|
add_subdirectory(tab_strip_selection)
|
||||||
|
add_subdirectory(workspace_compose)
|
||||||
|
|
||||||
add_custom_target(editor_ui_layout_integration_tests
|
add_custom_target(editor_ui_layout_integration_tests
|
||||||
DEPENDS
|
DEPENDS
|
||||||
editor_ui_layout_splitter_resize_validation
|
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();
|
return (RepoRootPath() / relativePath).lexically_normal();
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::array<EditorValidationScenario, 4>& GetEditorValidationScenarios() {
|
const std::array<EditorValidationScenario, 6>& GetEditorValidationScenarios() {
|
||||||
static const std::array<EditorValidationScenario, 4> scenarios = { {
|
static const std::array<EditorValidationScenario, 6> scenarios = { {
|
||||||
{
|
{
|
||||||
"editor.input.keyboard_focus",
|
"editor.input.keyboard_focus",
|
||||||
UIValidationDomain::Editor,
|
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/layout/splitter_resize/View.xcui"),
|
||||||
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"),
|
||||||
RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/captures")
|
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;
|
return scenarios;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES
|
|||||||
test_input_modifier_tracker.cpp
|
test_input_modifier_tracker.cpp
|
||||||
test_editor_validation_registry.cpp
|
test_editor_validation_registry.cpp
|
||||||
test_structured_editor_shell.cpp
|
test_structured_editor_shell.cpp
|
||||||
|
test_ui_editor_workspace_model.cpp
|
||||||
# Migration bridge: editor-facing XCUI primitive tests still reuse the
|
# Migration bridge: editor-facing XCUI primitive tests still reuse the
|
||||||
# legacy source location until they are relocated under tests/UI/Editor/unit.
|
# legacy source location until they are relocated under tests/UI/Editor/unit.
|
||||||
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_collection_primitives.cpp
|
${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* keyboardScenario = FindEditorValidationScenario("editor.input.keyboard_focus");
|
||||||
const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope");
|
const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope");
|
||||||
const auto* splitterScenario = FindEditorValidationScenario("editor.layout.splitter_resize");
|
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(pointerScenario, nullptr);
|
||||||
ASSERT_NE(keyboardScenario, nullptr);
|
ASSERT_NE(keyboardScenario, nullptr);
|
||||||
ASSERT_NE(shortcutScenario, nullptr);
|
ASSERT_NE(shortcutScenario, nullptr);
|
||||||
ASSERT_NE(splitterScenario, nullptr);
|
ASSERT_NE(splitterScenario, nullptr);
|
||||||
|
ASSERT_NE(tabStripScenario, nullptr);
|
||||||
|
ASSERT_NE(workspaceScenario, nullptr);
|
||||||
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor);
|
EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor);
|
||||||
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor);
|
EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor);
|
||||||
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor);
|
EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor);
|
||||||
EXPECT_EQ(splitterScenario->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(pointerScenario->categoryId, "input");
|
||||||
EXPECT_EQ(keyboardScenario->categoryId, "input");
|
EXPECT_EQ(keyboardScenario->categoryId, "input");
|
||||||
EXPECT_EQ(shortcutScenario->categoryId, "input");
|
EXPECT_EQ(shortcutScenario->categoryId, "input");
|
||||||
EXPECT_EQ(splitterScenario->categoryId, "layout");
|
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->documentPath));
|
||||||
EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath));
|
EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath));
|
||||||
EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath));
|
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(shortcutScenario->themePath));
|
||||||
EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath));
|
EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath));
|
||||||
EXPECT_TRUE(std::filesystem::exists(splitterScenario->themePath));
|
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) {
|
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
|
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_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_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
|
${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_runtime.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
add_executable(runtime_ui_tests ${RUNTIME_UI_TEST_SOURCES})
|
add_executable(runtime_ui_tests ${RUNTIME_UI_TEST_SOURCES})
|
||||||
|
|
||||||
if(MSVC)
|
if(MSVC)
|
||||||
|
target_compile_options(runtime_ui_tests PRIVATE /FS)
|
||||||
set_target_properties(runtime_ui_tests PROPERTIES
|
set_target_properties(runtime_ui_tests PROPERTIES
|
||||||
LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib"
|
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.pointer_states`
|
||||||
- `editor.input.shortcut_scope`
|
- `editor.input.shortcut_scope`
|
||||||
- `editor.layout.splitter_resize`
|
- `editor.layout.splitter_resize`
|
||||||
|
- `editor.layout.tab_strip_selection`
|
||||||
|
- `editor.layout.workspace_compose`
|
||||||
|
|
||||||
这些场景只用于验证 XCUI 模块能力,不代表开始复刻完整 editor 面板。
|
这些场景只用于验证 XCUI 模块能力,不代表开始复刻完整 editor 面板。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user