diff --git a/engine/include/XCEngine/UI/Layout/UITabStripLayout.h b/engine/include/XCEngine/UI/Layout/UITabStripLayout.h new file mode 100644 index 00000000..199e6a5b --- /dev/null +++ b/engine/include/XCEngine/UI/Layout/UITabStripLayout.h @@ -0,0 +1,133 @@ +#pragma once + +#include + +#include +#include +#include + +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 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& 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& 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(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(desiredHeaderWidths.size()); + result.tabHeaderRects[index] = UIRect(cursorX, bounds.y, width, headerHeight); + cursorX += width + gap; + } + + return result; +} + +} // namespace Layout +} // namespace UI +} // namespace XCEngine diff --git a/engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h b/engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h index b19f7695..f8bb8178 100644 --- a/engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h +++ b/engine/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -91,6 +92,7 @@ private: UIInputDispatcher m_inputDispatcher; std::unordered_map m_verticalScrollOffsets = {}; std::unordered_map m_splitterRatios = {}; + std::unordered_map m_tabStripSelectedIndices = {}; PointerState m_pointerState = {}; SplitterDragRuntimeState m_splitterDragState = {}; InputDebugSnapshot m_inputDebugSnapshot = {}; diff --git a/engine/include/XCEngine/UI/Widgets/UITabStripModel.h b/engine/include/XCEngine/UI/Widgets/UITabStripModel.h new file mode 100644 index 00000000..c0565852 --- /dev/null +++ b/engine/include/XCEngine/UI/Widgets/UITabStripModel.h @@ -0,0 +1,110 @@ +#pragma once + +#include +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +class UITabStripModel { +public: + static constexpr std::size_t InvalidIndex = static_cast(-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 diff --git a/engine/src/UI/Runtime/UIScreenDocumentHost.cpp b/engine/src/UI/Runtime/UIScreenDocumentHost.cpp index 38e80ebd..dd92dd3e 100644 --- a/engine/src/UI/Runtime/UIScreenDocumentHost.cpp +++ b/engine/src/UI/Runtime/UIScreenDocumentHost.cpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include #include #include @@ -36,6 +38,7 @@ namespace Layout = XCEngine::UI::Layout; constexpr float kDefaultFontSize = 16.0f; constexpr float kSmallFontSize = 13.0f; constexpr float kButtonFontSize = 14.0f; +constexpr float kTabFontSize = 13.0f; constexpr float kApproximateGlyphWidth = 0.56f; constexpr float kHeaderTextInset = 12.0f; constexpr float kHeaderTextGap = 2.0f; @@ -57,6 +60,8 @@ struct RuntimeLayoutNode { bool wantsPointerCapture = false; bool isScrollView = false; bool isSplitter = false; + bool isTabStrip = false; + bool isTab = false; bool textInput = false; Layout::UILayoutAxis splitterAxis = Layout::UILayoutAxis::Horizontal; Layout::UISplitterMetrics splitterMetrics = {}; @@ -64,6 +69,13 @@ struct RuntimeLayoutNode { float splitterRatio = 0.5f; UIRect splitterHandleRect = {}; UIRect splitterHandleHitRect = {}; + Layout::UITabStripMetrics tabStripMetrics = {}; + std::size_t tabStripSelectedIndex = 0u; + std::size_t tabIndex = 0u; + UIRect tabStripHeaderRect = {}; + UIRect tabStripContentRect = {}; + UIRect tabHeaderRect = {}; + bool tabSelected = false; bool hasShortcutBinding = false; UIShortcutBinding shortcutBinding = {}; enum class ShortcutScopeRoot : std::uint8_t { @@ -175,6 +187,8 @@ std::string GetAttribute( return attribute != nullptr ? ToStdString(attribute->value) : fallback; } +std::string ResolveNodeText(const UIDocumentNode& node); + bool ParseBoolAttribute( const UIDocumentNode& node, const char* name, @@ -446,6 +460,42 @@ float MeasureHeaderHeight(const UIDocumentNode& node) { return headerHeight; } +bool TryParseIndexValue(const std::string& text, std::size_t& outValue) { + if (text.empty()) { + return false; + } + + std::size_t value = 0u; + for (unsigned char ch : text) { + if (std::isspace(ch)) { + continue; + } + + if (!std::isdigit(ch)) { + return false; + } + + value = value * 10u + static_cast(ch - '0'); + } + + outValue = value; + return true; +} + +std::string ResolveTabLabelText(const UIDocumentNode& node) { + std::string label = GetAttribute(node, "label"); + if (!label.empty()) { + return label; + } + + label = GetAttribute(node, "title"); + if (!label.empty()) { + return label; + } + + return ResolveNodeText(node); +} + bool TryParseFloat(const std::string& text, float& outValue) { if (text.empty()) { return false; @@ -468,6 +518,28 @@ bool TryParseFloat(const std::string& text, float& outValue) { return true; } +std::size_t ResolveInitialTabStripSelectedIndex( + const UIDocumentNode& node, + std::size_t childCount) { + if (childCount == 0u) { + return Widgets::UITabStripModel::InvalidIndex; + } + + std::size_t attributeIndex = 0u; + if (TryParseIndexValue(GetAttribute(node, "selectedIndex"), attributeIndex) && + attributeIndex < childCount) { + return attributeIndex; + } + + for (std::size_t index = 0; index < childCount; ++index) { + if (ParseBoolAttribute(node.children[index], "selected", false)) { + return index; + } + } + + return 0u; +} + float ParseFloatAttribute( const UIDocumentNode& node, const char* name, @@ -577,6 +649,47 @@ std::string ResolveNodeText(const UIDocumentNode& node) { return ToStdString(node.tagName); } +std::size_t ResolveTabStripSelectedIndex( + const UIDocumentNode& source, + const std::string& stateKey, + std::size_t childCount, + const std::unordered_map& tabStripSelectedIndices) { + Widgets::UITabStripModel model = {}; + model.SetItemCount(childCount); + + const auto found = tabStripSelectedIndices.find(stateKey); + if (found != tabStripSelectedIndices.end() && + found->second != Widgets::UITabStripModel::InvalidIndex) { + model.SetSelectedIndex(found->second); + } else { + const std::size_t initialIndex = ResolveInitialTabStripSelectedIndex(source, childCount); + if (initialIndex != Widgets::UITabStripModel::InvalidIndex) { + model.SetSelectedIndex(initialIndex); + } + } + + return model.HasSelection() ? model.GetSelectedIndex() : 0u; +} + +void ApplyTabStripSelection(RuntimeLayoutNode& tabStripNode, std::size_t selectedIndex) { + Widgets::UITabStripModel model = {}; + model.SetItemCount(tabStripNode.children.size()); + if (!tabStripNode.children.empty()) { + model.SetSelectedIndex((std::min)(selectedIndex, tabStripNode.children.size() - 1u)); + } + + tabStripNode.tabStripSelectedIndex = model.HasSelection() ? model.GetSelectedIndex() : 0u; + for (std::size_t index = 0; index < tabStripNode.children.size(); ++index) { + RuntimeLayoutNode& child = tabStripNode.children[index]; + child.tabIndex = index; + child.tabSelected = model.HasSelection() && index == model.GetSelectedIndex(); + } +} + +bool AreTabChildrenVisible(const RuntimeLayoutNode& node) { + return !node.isTab || node.tabSelected; +} + bool IsHorizontalTag(const std::string& tagName) { return tagName == "Row"; } @@ -589,6 +702,14 @@ bool IsSplitterTag(const std::string& tagName) { return tagName == "Splitter"; } +bool IsTabStripTag(const std::string& tagName) { + return tagName == "TabStrip"; +} + +bool IsTabTag(const std::string& tagName) { + return tagName == "Tab"; +} + bool IsButtonTag(const std::string& tagName) { return tagName == "Button"; } @@ -625,6 +746,27 @@ Layout::UISplitterMetrics ParseSplitterMetrics(const UIDocumentNode& node) { return metrics; } +Layout::UITabStripMetrics ParseTabStripMetrics(const UIDocumentNode& node) { + Layout::UITabStripMetrics metrics = {}; + metrics.headerHeight = (std::max)( + 24.0f, + ParseFloatAttributeAny( + node, + "tabHeaderHeight", + "tabHeight", + ParseFloatAttribute(node, "headerHeight", metrics.headerHeight))); + metrics.tabMinWidth = (std::max)( + 32.0f, + ParseFloatAttributeAny(node, "tabMinWidth", "minTabWidth", metrics.tabMinWidth)); + metrics.tabHorizontalPadding = (std::max)( + 0.0f, + ParseFloatAttributeAny(node, "tabPaddingX", "tabHorizontalPadding", metrics.tabHorizontalPadding)); + metrics.tabGap = (std::max)( + 0.0f, + ParseFloatAttributeAny(node, "tabGap", "headerGap", metrics.tabGap)); + return metrics; +} + bool IsContainerTag(const UIDocumentNode& node) { if (node.children.Size() > 0u) { return true; @@ -635,6 +777,8 @@ bool IsContainerTag(const UIDocumentNode& node) { tagName == "Column" || tagName == "Row" || tagName == "Splitter" || + tagName == "TabStrip" || + tagName == "Tab" || tagName == "ScrollView" || tagName == "Card" || tagName == "Button"; @@ -645,12 +789,12 @@ bool IsPointerInteractiveNode(const UIDocumentNode& node) { return ParseBoolAttribute( node, "interactive", - IsButtonTag(tagName) || IsScrollViewTag(tagName) || IsSplitterTag(tagName)); + IsButtonTag(tagName) || IsScrollViewTag(tagName) || IsSplitterTag(tagName) || IsTabTag(tagName)); } bool IsFocusableNode(const UIDocumentNode& node) { const std::string tagName = ToStdString(node.tagName); - return ParseBoolAttribute(node, "focusable", IsButtonTag(tagName)); + return ParseBoolAttribute(node, "focusable", IsButtonTag(tagName) || IsTabTag(tagName)); } bool WantsPointerCapture(const UIDocumentNode& node) { @@ -713,11 +857,15 @@ const UIRect& GetNodeInteractionRect(const RuntimeLayoutNode& node) { return node.splitterHandleHitRect; } + if (node.isTab) { + return node.tabHeaderRect; + } + return node.isScrollView ? node.scrollViewportRect : node.rect; } bool IsNodeTargetable(const RuntimeLayoutNode& node) { - return node.pointerInteractive || node.focusable || node.isScrollView || node.isSplitter; + return node.pointerInteractive || node.focusable || node.isScrollView || node.isSplitter || node.isTab; } const RuntimeLayoutNode* FindNodeByElementId( @@ -752,6 +900,37 @@ RuntimeLayoutNode* FindNodeByElementId( return nullptr; } +RuntimeLayoutNode* FindParentTabStripNode(RuntimeLayoutNode& root, const RuntimeLayoutNode& node) { + if (!node.isTab || node.inputPath.elements.size() < 2u) { + return nullptr; + } + + RuntimeLayoutNode* parent = FindNodeByElementId( + root, + node.inputPath.elements[node.inputPath.elements.size() - 2u]); + return parent != nullptr && parent->isTabStrip ? parent : nullptr; +} + +const RuntimeLayoutNode* FindVisibleNodeByElementId( + const RuntimeLayoutNode& node, + UIElementId elementId) { + if (node.elementId == elementId) { + return &node; + } + + if (!AreTabChildrenVisible(node)) { + return nullptr; + } + + for (const RuntimeLayoutNode& child : node.children) { + if (const RuntimeLayoutNode* found = FindVisibleNodeByElementId(child, elementId); found != nullptr) { + return found; + } + } + + return nullptr; +} + bool CollectNodesForInputPath( const RuntimeLayoutNode& node, const UIInputPath& path, @@ -766,6 +945,11 @@ bool CollectNodesForInputPath( return true; } + if (!AreTabChildrenVisible(node)) { + outNodes.pop_back(); + return false; + } + for (const RuntimeLayoutNode& child : node.children) { if (CollectNodesForInputPath(child, path, index + 1u, outNodes)) { return true; @@ -793,7 +977,7 @@ std::string ResolveStateKeyForElementId( bool PathTargetExists( const RuntimeLayoutNode& root, const UIInputPath& path) { - return !path.Empty() && FindNodeByElementId(root, path.Target()) != nullptr; + return !path.Empty() && FindVisibleNodeByElementId(root, path.Target()) != nullptr; } bool SplitterTargetExists( @@ -843,7 +1027,26 @@ std::string ValidateRuntimeLayoutTree(const RuntimeLayoutNode& node) { return "Splitter '" + splitterName + "' must contain exactly 2 child elements."; } + if (node.isTabStrip) { + const std::string tabStripName = node.stateKey.empty() + ? std::string("") + : node.stateKey; + if (node.children.empty()) { + return "TabStrip '" + tabStripName + "' must contain at least 1 Tab child."; + } + + for (const RuntimeLayoutNode& child : node.children) { + if (!child.isTab) { + return "TabStrip '" + tabStripName + "' may only contain Tab children."; + } + } + } + for (const RuntimeLayoutNode& child : node.children) { + if (child.isTab && !node.isTabStrip) { + return "Tab '" + child.stateKey + "' must be parented directly by a TabStrip."; + } + const std::string error = ValidateRuntimeLayoutTree(child); if (!error.empty()) { return error; @@ -923,6 +1126,10 @@ void RegisterShortcutBindings( registry.RegisterBinding(node.shortcutBinding); } + if (!AreTabChildrenVisible(node)) { + return; + } + for (const RuntimeLayoutNode& child : node.children) { RegisterShortcutBindings(child, registry); } @@ -964,6 +1171,10 @@ void CollectFocusablePaths( outPaths.push_back(node.inputPath); } + if (!AreTabChildrenVisible(node)) { + return; + } + for (const RuntimeLayoutNode& child : node.children) { CollectFocusablePaths(child, outPaths); } @@ -1036,9 +1247,11 @@ const RuntimeLayoutNode* FindDeepestInputTarget( return &node; } - for (const RuntimeLayoutNode& child : node.children) { - if (const RuntimeLayoutNode* found = FindDeepestInputTarget(child, point, &nextClip); found != nullptr) { - return found; + if (AreTabChildrenVisible(node)) { + for (const RuntimeLayoutNode& child : node.children) { + if (const RuntimeLayoutNode* found = FindDeepestInputTarget(child, point, &nextClip); found != nullptr) { + return found; + } } } @@ -1149,8 +1362,10 @@ RuntimeLayoutNode BuildLayoutTree( const UIDocumentNode& source, const std::string& parentStateKey, const UIInputPath& parentInputPath, - std::size_t siblingIndex) { + std::size_t siblingIndex, + const std::unordered_map& tabStripSelectedIndices) { RuntimeLayoutNode node = {}; + const std::string tagName = ToStdString(source.tagName); node.source = &source; node.stateKey = parentStateKey + "/" + BuildNodeStateKeySegment(source, siblingIndex); node.elementId = HashStateKeyToElementId(node.stateKey); @@ -1159,11 +1374,14 @@ RuntimeLayoutNode BuildLayoutTree( node.pointerInteractive = IsPointerInteractiveNode(source); node.focusable = ParseBoolAttribute(source, "focusable", IsFocusableNode(source)); node.wantsPointerCapture = WantsPointerCapture(source); - node.isScrollView = IsScrollViewTag(ToStdString(source.tagName)); - node.isSplitter = IsSplitterTag(ToStdString(source.tagName)); + node.isScrollView = IsScrollViewTag(tagName); + node.isSplitter = IsSplitterTag(tagName); + node.isTabStrip = IsTabStripTag(tagName); + node.isTab = IsTabTag(tagName); node.textInput = IsTextInputNode(source); node.splitterAxis = ParseAxisAttribute(source, "axis", Layout::UILayoutAxis::Horizontal); node.splitterMetrics = ParseSplitterMetrics(source); + node.tabStripMetrics = ParseTabStripMetrics(source); node.splitterRatio = ParseRatioAttribute( source, "splitRatio", @@ -1172,8 +1390,24 @@ RuntimeLayoutNode BuildLayoutTree( node.hasShortcutBinding = TryBuildShortcutBinding(source, node.elementId, node.shortcutBinding); node.children.reserve(source.children.Size()); for (std::size_t index = 0; index < source.children.Size(); ++index) { - node.children.push_back(BuildLayoutTree(source.children[index], node.stateKey, node.inputPath, index)); + node.children.push_back(BuildLayoutTree( + source.children[index], + node.stateKey, + node.inputPath, + index, + tabStripSelectedIndices)); } + + if (node.isTabStrip) { + ApplyTabStripSelection( + node, + ResolveTabStripSelectedIndex( + source, + node.stateKey, + node.children.size(), + tabStripSelectedIndices)); + } + return node; } @@ -1260,6 +1494,40 @@ UISize MeasureNode(RuntimeLayoutNode& node) { return node.desiredSize; } + if (node.isTabStrip) { + std::vector items = {}; + items.reserve(node.children.size()); + for (RuntimeLayoutNode& child : node.children) { + MeasureNode(child); + Layout::UITabStripMeasureItem item = {}; + item.desiredHeaderLabelWidth = MeasureTextWidth( + ResolveTabLabelText(*child.source), + kTabFontSize); + item.desiredContentSize = child.desiredSize; + item.minimumContentSize = child.minimumSize; + items.push_back(item); + } + + const Layout::UITabStripMeasureResult measured = Layout::MeasureUITabStrip( + items, + node.tabStripMetrics); + node.contentDesiredSize = measured.desiredSize; + node.desiredSize = measured.desiredSize; + node.minimumSize = measured.minimumSize; + + float explicitWidth = 0.0f; + if (TryParseFloat(GetAttribute(source, "width"), explicitWidth)) { + node.desiredSize.width = (std::max)(node.desiredSize.width, explicitWidth); + } + + float explicitHeight = 0.0f; + if (TryParseFloat(GetAttribute(source, "height"), explicitHeight)) { + node.desiredSize.height = (std::max)(node.desiredSize.height, explicitHeight); + } + + return node.desiredSize; + } + Layout::UIStackLayoutOptions options = {}; options.axis = IsHorizontalTag(tagName) ? Layout::UILayoutAxis::Horizontal @@ -1288,20 +1556,22 @@ UISize MeasureNode(RuntimeLayoutNode& node) { node.desiredSize = measured.desiredSize; node.minimumSize = minimumMeasured.desiredSize; - const float headerHeight = MeasureHeaderHeight(source); - const float headerTextWidth = MeasureHeaderTextWidth(source); + const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source); + const float headerTextWidth = node.isTab ? 0.0f : MeasureHeaderTextWidth(source); - node.desiredSize.width = (std::max)( - node.desiredSize.width, - headerTextWidth > 0.0f - ? headerTextWidth + options.padding.Horizontal() - : MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal()); + if (!node.isTab) { + node.desiredSize.width = (std::max)( + node.desiredSize.width, + headerTextWidth > 0.0f + ? headerTextWidth + options.padding.Horizontal() + : MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal()); + node.minimumSize.width = (std::max)( + node.minimumSize.width, + headerTextWidth > 0.0f + ? headerTextWidth + options.padding.Horizontal() + : MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal()); + } node.desiredSize.height += headerHeight; - node.minimumSize.width = (std::max)( - node.minimumSize.width, - headerTextWidth > 0.0f - ? headerTextWidth + options.padding.Horizontal() - : MeasureTextWidth(ResolveNodeText(source), kDefaultFontSize) + options.padding.Horizontal()); node.minimumSize.height += headerHeight; if (node.isScrollView) { @@ -1331,6 +1601,9 @@ void ArrangeNode( node.scrollOffsetY = 0.0f; node.splitterHandleRect = {}; node.splitterHandleHitRect = {}; + node.tabStripHeaderRect = {}; + node.tabStripContentRect = {}; + node.tabHeaderRect = {}; const UIDocumentNode& source = *node.source; if (!IsContainerTag(source)) { @@ -1367,6 +1640,37 @@ void ArrangeNode( return; } + if (node.isTabStrip) { + std::vector desiredHeaderWidths = {}; + desiredHeaderWidths.reserve(node.children.size()); + for (const RuntimeLayoutNode& child : node.children) { + desiredHeaderWidths.push_back(Layout::MeasureUITabStripHeaderWidth( + MeasureTextWidth(ResolveTabLabelText(*child.source), kTabFontSize), + node.tabStripMetrics)); + } + + const Layout::UITabStripLayoutResult arranged = Layout::ArrangeUITabStrip( + rect, + desiredHeaderWidths, + node.tabStripMetrics); + node.tabStripHeaderRect = arranged.headerRect; + node.tabStripContentRect = arranged.contentRect; + for (std::size_t index = 0; index < node.children.size(); ++index) { + RuntimeLayoutNode& child = node.children[index]; + child.tabIndex = index; + child.tabSelected = index == node.tabStripSelectedIndex; + ArrangeNode( + child, + arranged.contentRect, + verticalScrollOffsets, + splitterRatios); + child.tabHeaderRect = index < arranged.tabHeaderRects.size() + ? arranged.tabHeaderRects[index] + : UIRect(); + } + return; + } + const std::string tagName = ToStdString(source.tagName); Layout::UIStackLayoutOptions options = {}; options.axis = IsHorizontalTag(tagName) @@ -1380,7 +1684,7 @@ void ArrangeNode( source, tagName == "View" ? 16.0f : 12.0f); - const float headerHeight = MeasureHeaderHeight(source); + const float headerHeight = node.isTab ? 0.0f : MeasureHeaderHeight(source); UIRect contentRect = rect; contentRect.y += headerHeight; @@ -1428,9 +1732,11 @@ void ArrangeNode( RuntimeLayoutNode* FindDeepestScrollTarget( RuntimeLayoutNode& node, const UIPoint& point) { - for (RuntimeLayoutNode& child : node.children) { - if (RuntimeLayoutNode* target = FindDeepestScrollTarget(child, point); target != nullptr) { - return target; + if (AreTabChildrenVisible(node)) { + for (RuntimeLayoutNode& child : node.children) { + if (RuntimeLayoutNode* target = FindDeepestScrollTarget(child, point); target != nullptr) { + return target; + } } } @@ -1452,9 +1758,11 @@ RuntimeLayoutNode* FindDeepestScrollTarget( RuntimeLayoutNode* FindDeepestHoveredScrollView( RuntimeLayoutNode& node, const UIPoint& point) { - for (RuntimeLayoutNode& child : node.children) { - if (RuntimeLayoutNode* hovered = FindDeepestHoveredScrollView(child, point); hovered != nullptr) { - return hovered; + if (AreTabChildrenVisible(node)) { + for (RuntimeLayoutNode& child : node.children) { + if (RuntimeLayoutNode* hovered = FindDeepestHoveredScrollView(child, point); hovered != nullptr) { + return hovered; + } } } @@ -1472,9 +1780,11 @@ const RuntimeLayoutNode* FindFirstScrollView(const RuntimeLayoutNode& node) { return &node; } - for (const RuntimeLayoutNode& child : node.children) { - if (const RuntimeLayoutNode* found = FindFirstScrollView(child); found != nullptr) { - return found; + if (AreTabChildrenVisible(node)) { + for (const RuntimeLayoutNode& child : node.children) { + if (const RuntimeLayoutNode* found = FindFirstScrollView(child); found != nullptr) { + return found; + } } } @@ -1672,12 +1982,153 @@ void UpdateInputDebugSnapshot( shortcutContext.commandScope.widgetId); } +enum class TabStripSelectionCommand : std::uint8_t { + None = 0, + SelectPrevious, + SelectNext, + SelectFirst, + SelectLast +}; + +TabStripSelectionCommand ResolveTabStripSelectionCommand(const UIInputEvent& event) { + if (event.type != UIInputEventType::KeyDown || + event.modifiers.control || + event.modifiers.alt || + event.modifiers.super) { + return TabStripSelectionCommand::None; + } + + switch (static_cast(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& 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 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& tabStripSelectedIndices, + UIDocumentScreenHost::InputDebugSnapshot& inputDebugSnapshot, + bool& layoutChanged) { + layoutChanged = false; + if (inputDispatcher.GetShortcutContext().textInputActive) { + return false; + } + + const TabStripSelectionCommand selectionCommand = ResolveTabStripSelectionCommand(event); + if (selectionCommand == TabStripSelectionCommand::None) { + return false; + } + + const RuntimeLayoutNode* focusedTabNode = FindInnermostVisibleTabNodeForPath( + root, + inputDispatcher.GetFocusController().GetFocusedPath()); + if (focusedTabNode == nullptr) { + return false; + } + + RuntimeLayoutNode* focusedTab = FindNodeByElementId(root, focusedTabNode->elementId); + if (focusedTab == nullptr) { + return false; + } + + RuntimeLayoutNode* tabStripNode = FindParentTabStripNode(root, *focusedTab); + if (tabStripNode == nullptr || tabStripNode->children.empty()) { + return false; + } + + Widgets::UITabStripModel model = {}; + model.SetItemCount(tabStripNode->children.size()); + model.SetSelectedIndex((std::min)(focusedTab->tabIndex, tabStripNode->children.size() - 1u)); + + switch (selectionCommand) { + case TabStripSelectionCommand::SelectPrevious: + layoutChanged = model.SelectPrevious(); + break; + case TabStripSelectionCommand::SelectNext: + layoutChanged = model.SelectNext(); + break; + case TabStripSelectionCommand::SelectFirst: + layoutChanged = model.SelectFirst(); + break; + case TabStripSelectionCommand::SelectLast: + layoutChanged = model.SelectLast(); + break; + case TabStripSelectionCommand::None: + default: + return false; + } + + UpdateTabStripSelection( + *tabStripNode, + model, + tabStripSelectedIndices, + inputDispatcher.GetFocusController()); + + const RuntimeLayoutNode& targetTab = tabStripNode->children[tabStripNode->tabStripSelectedIndex]; + inputDebugSnapshot.lastTargetKind = "Focused"; + inputDebugSnapshot.lastTargetStateKey = targetTab.stateKey; + inputDebugSnapshot.lastResult = layoutChanged + ? "Tab navigated" + : "Tab navigation unchanged"; + return true; +} + bool DispatchInputEvent( RuntimeLayoutNode& root, const UIInputEvent& event, const UIInputPath& hoveredPath, UIInputDispatcher& inputDispatcher, std::unordered_map& splitterRatios, + std::unordered_map& tabStripSelectedIndices, UIDocumentScreenHost::SplitterDragRuntimeState& splitterDragState, UIDocumentScreenHost::InputDebugSnapshot& inputDebugSnapshot) { if (event.type == UIInputEventType::PointerWheel) { @@ -1725,9 +2176,22 @@ bool DispatchInputEvent( return false; } + bool focusedTabNavigationChanged = false; + if (TryHandleFocusedTabStripSelectionCommand( + root, + event, + inputDispatcher, + tabStripSelectedIndices, + inputDebugSnapshot, + focusedTabNavigationChanged)) { + return focusedTabNavigationChanged; + } + bool pointerCaptureStarted = false; bool layoutChanged = false; bool splitterDragFinished = false; + bool tabSelectionTriggered = false; + bool tabSelectionChanged = false; const UIInputDispatchSummary summary = inputDispatcher.Dispatch( event, hoveredPath, @@ -1736,7 +2200,7 @@ bool DispatchInputEvent( return UIInputDispatchDecision{}; } - const RuntimeLayoutNode* node = FindNodeByElementId(root, request.elementId); + RuntimeLayoutNode* node = FindNodeByElementId(root, request.elementId); if (node == nullptr) { return UIInputDispatchDecision{}; } @@ -1811,6 +2275,25 @@ bool DispatchInputEvent( return UIInputDispatchDecision{ true, false }; } + if (node->isTab && + event.type == UIInputEventType::PointerButtonUp && + event.pointerButton == UIPointerButton::Left) { + if (RuntimeLayoutNode* tabStripNode = FindParentTabStripNode(root, *node); + tabStripNode != nullptr) { + tabSelectionTriggered = true; + tabSelectionChanged = tabSelectionChanged || tabStripNode->tabStripSelectedIndex != node->tabIndex; + Widgets::UITabStripModel model = {}; + model.SetItemCount(tabStripNode->children.size()); + model.SetSelectedIndex(node->tabIndex); + UpdateTabStripSelection( + *tabStripNode, + model, + tabStripSelectedIndices, + inputDispatcher.GetFocusController()); + return UIInputDispatchDecision{ true, false }; + } + } + if (event.type == UIInputEventType::PointerButtonDown && event.pointerButton == UIPointerButton::Left && node->wantsPointerCapture) { @@ -1846,6 +2329,10 @@ bool DispatchInputEvent( inputDebugSnapshot.lastResult = "Shortcut suppressed by text input"; } else if (focusTraversalSuppressed) { inputDebugSnapshot.lastResult = "Focus traversal suppressed by text input"; + } else if (tabSelectionTriggered) { + inputDebugSnapshot.lastResult = tabSelectionChanged + ? "Tab selected" + : "Tab selection unchanged"; } else if (pointerCaptureStarted) { inputDebugSnapshot.lastResult = splitterDragState.drag.active ? "Splitter drag started" @@ -1899,6 +2386,18 @@ void SyncSplitterRatios( } } +void SyncTabStripSelectedIndices( + const RuntimeLayoutNode& node, + std::unordered_map& tabStripSelectedIndices) { + if (node.isTabStrip && !node.children.empty()) { + tabStripSelectedIndices[node.stateKey] = node.tabStripSelectedIndex; + } + + for (const RuntimeLayoutNode& child : node.children) { + SyncTabStripSelectedIndices(child, tabStripSelectedIndices); + } +} + void EmitNode( const RuntimeLayoutNode& node, const UIInputPath& hoveredPath, @@ -1927,6 +2426,75 @@ void EmitNode( } } + if (node.isTabStrip) { + if (HasPositiveArea(node.rect)) { + drawList.AddFilledRect(node.rect, ToUIColor(Color(0.18f, 0.18f, 0.18f, 1.0f)), 8.0f); + ++stats.filledRectCommandCount; + } + + if (HasPositiveArea(node.tabStripHeaderRect)) { + drawList.AddFilledRect(node.tabStripHeaderRect, ToUIColor(Color(0.16f, 0.16f, 0.16f, 1.0f)), 8.0f); + ++stats.filledRectCommandCount; + } + + if (HasPositiveArea(node.tabStripContentRect)) { + drawList.AddFilledRect(node.tabStripContentRect, ToUIColor(Color(0.20f, 0.20f, 0.20f, 1.0f)), 8.0f); + ++stats.filledRectCommandCount; + drawList.AddRectOutline( + node.tabStripContentRect, + ToUIColor(Color(0.26f, 0.26f, 0.26f, 1.0f)), + 1.0f, + 8.0f); + } + + for (const RuntimeLayoutNode& child : node.children) { + if (!HasPositiveArea(child.tabHeaderRect)) { + continue; + } + + const RuntimeNodeVisualState tabState = ResolveNodeVisualState( + child, + hoveredPath, + focusController); + Color fillColor = child.tabSelected + ? Color(0.24f, 0.24f, 0.24f, 1.0f) + : Color(0.15f, 0.15f, 0.15f, 1.0f); + if (tabState.capture || tabState.active) { + fillColor = child.tabSelected + ? Color(0.31f, 0.31f, 0.31f, 1.0f) + : Color(0.24f, 0.24f, 0.24f, 1.0f); + } else if (tabState.hovered) { + fillColor = child.tabSelected + ? Color(0.28f, 0.28f, 0.28f, 1.0f) + : Color(0.20f, 0.20f, 0.20f, 1.0f); + } + + drawList.AddFilledRect(child.tabHeaderRect, ToUIColor(fillColor), 6.0f); + ++stats.filledRectCommandCount; + drawList.AddRectOutline( + child.tabHeaderRect, + ToUIColor(child.tabSelected + ? Color(0.42f, 0.42f, 0.42f, 1.0f) + : Color(0.24f, 0.24f, 0.24f, 1.0f)), + tabState.focused || child.tabSelected ? 2.0f : 1.0f, + 6.0f); + + const std::string label = ResolveTabLabelText(*child.source); + if (!label.empty()) { + drawList.AddText( + UIPoint( + child.tabHeaderRect.x + node.tabStripMetrics.tabHorizontalPadding, + ComputeCenteredTextTop(child.tabHeaderRect, kTabFontSize)), + label, + ToUIColor(child.tabSelected + ? Color(0.95f, 0.95f, 0.95f, 1.0f) + : Color(0.78f, 0.78f, 0.78f, 1.0f)), + kTabFontSize); + ++stats.textCommandCount; + } + } + } + if (node.isSplitter && HasPositiveArea(node.splitterHandleRect)) { const Color splitterColor = visualState.capture || visualState.active @@ -1948,8 +2516,8 @@ void EmitNode( ++stats.filledRectCommandCount; } - const std::string title = GetAttribute(source, "title"); - const std::string subtitle = GetAttribute(source, "subtitle"); + const std::string title = (node.isTab || node.isTabStrip) ? std::string() : GetAttribute(source, "title"); + const std::string subtitle = (node.isTab || node.isTabStrip) ? std::string() : GetAttribute(source, "subtitle"); float textY = node.rect.y + kHeaderTextInset; if (!title.empty()) { drawList.AddText( @@ -1999,6 +2567,10 @@ void EmitNode( } for (const RuntimeLayoutNode& child : node.children) { + if (child.isTab && !child.tabSelected) { + continue; + } + EmitNode(child, hoveredPath, focusController, drawList, stats); } @@ -2119,7 +2691,12 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame( const std::string stateRoot = document.sourcePath.empty() ? document.displayName : document.sourcePath; - RuntimeLayoutNode root = BuildLayoutTree(document.viewDocument.rootNode, stateRoot, UIInputPath(), 0u); + RuntimeLayoutNode root = BuildLayoutTree( + document.viewDocument.rootNode, + stateRoot, + UIInputPath(), + 0u, + m_tabStripSelectedIndices); result.errorMessage = ValidateRuntimeLayoutTree(root); if (!result.errorMessage.empty()) { return result; @@ -2191,6 +2768,7 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame( eventHoveredPath, m_inputDispatcher, m_splitterRatios, + m_tabStripSelectedIndices, m_splitterDragState, m_inputDebugSnapshot)) { ArrangeNode(root, viewportRect, m_verticalScrollOffsets, m_splitterRatios); @@ -2219,6 +2797,8 @@ UIScreenFrameResult UIDocumentScreenHost::BuildFrame( SyncScrollOffsets(root, m_verticalScrollOffsets); m_splitterRatios.clear(); SyncSplitterRatios(root, m_splitterRatios); + m_tabStripSelectedIndices.clear(); + SyncTabStripSelectedIndices(root, m_tabStripSelectedIndices); const UIFocusController& focusController = m_inputDispatcher.GetFocusController(); diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index 2818a581..66d30a05 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -13,6 +13,7 @@ set(NEW_EDITOR_RESOURCE_FILES add_library(XCNewEditorLib STATIC src/editor/EditorShellAsset.cpp + src/editor/UIEditorWorkspaceModel.cpp src/Widgets/UIEditorCollectionPrimitives.cpp ) diff --git a/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h b/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h new file mode 100644 index 00000000..584d9e0a --- /dev/null +++ b/new_editor/include/XCNewEditor/Editor/UIEditorWorkspaceModel.h @@ -0,0 +1,108 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 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 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 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 diff --git a/new_editor/src/Host/AutoScreenshot.cpp b/new_editor/src/Host/AutoScreenshot.cpp index 8a2b5658..44f6803c 100644 --- a/new_editor/src/Host/AutoScreenshot.cpp +++ b/new_editor/src/Host/AutoScreenshot.cpp @@ -42,6 +42,14 @@ void AutoScreenshotController::CaptureIfRequested( } std::error_code errorCode = {}; + std::filesystem::create_directories(m_captureRoot, errorCode); + if (errorCode) { + m_lastCaptureError = "Failed to create screenshot directory: " + m_captureRoot.string(); + m_lastCaptureSummary = "AutoShot failed"; + m_capturePending = false; + return; + } + std::filesystem::create_directories(m_historyRoot, errorCode); if (errorCode) { m_lastCaptureError = "Failed to create screenshot directory: " + m_historyRoot.string(); @@ -52,14 +60,26 @@ void AutoScreenshotController::CaptureIfRequested( std::string captureError = {}; const std::filesystem::path historyPath = BuildHistoryCapturePath(m_pendingReason); - if (!renderer.CaptureToPng(drawData, width, height, m_latestCapturePath, captureError) || - !renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) { + if (!renderer.CaptureToPng(drawData, width, height, historyPath, captureError)) { m_lastCaptureError = std::move(captureError); m_lastCaptureSummary = "AutoShot failed"; m_capturePending = false; return; } + errorCode.clear(); + std::filesystem::copy_file( + historyPath, + m_latestCapturePath, + std::filesystem::copy_options::overwrite_existing, + errorCode); + if (errorCode) { + m_lastCaptureError = "Failed to update latest screenshot: " + m_latestCapturePath.string(); + m_lastCaptureSummary = "AutoShot failed"; + m_capturePending = false; + return; + } + ++m_captureCount; m_lastCaptureError.clear(); m_lastCaptureSummary = diff --git a/new_editor/src/editor/UIEditorWorkspaceModel.cpp b/new_editor/src/editor/UIEditorWorkspaceModel.cpp new file mode 100644 index 00000000..771ec5c5 --- /dev/null +++ b/new_editor/src/editor/UIEditorWorkspaceModel.cpp @@ -0,0 +1,298 @@ +#include + +#include +#include +#include + +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& 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& 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 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 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 CollectUIEditorWorkspaceVisiblePanels( + const UIEditorWorkspaceModel& workspace) { + std::vector 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 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 diff --git a/tests/UI/Core/unit/CMakeLists.txt b/tests/UI/Core/unit/CMakeLists.txt index b9bfd39c..0e305236 100644 --- a/tests/UI/Core/unit/CMakeLists.txt +++ b/tests/UI/Core/unit/CMakeLists.txt @@ -2,6 +2,8 @@ set(CORE_UI_TEST_SOURCES ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_shortcut_scope.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_layout.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_splitter_interaction.cpp + ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp + ${CMAKE_SOURCE_DIR}/tests/UI/Core/unit/test_ui_tab_strip_model.cpp # Migration bridge: legacy XCUI unit coverage still lives under tests/Core/UI # until it is moved into tests/UI/Core/unit without changing behavior. ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_core.cpp diff --git a/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp b/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp new file mode 100644 index 00000000..9950743a --- /dev/null +++ b/tests/UI/Core/unit/test_ui_tab_strip_layout.cpp @@ -0,0 +1,72 @@ +#include + +#include + +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); +} diff --git a/tests/UI/Core/unit/test_ui_tab_strip_model.cpp b/tests/UI/Core/unit/test_ui_tab_strip_model.cpp new file mode 100644 index 00000000..18106f12 --- /dev/null +++ b/tests/UI/Core/unit/test_ui_tab_strip_model.cpp @@ -0,0 +1,53 @@ +#include + +#include + +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)); +} diff --git a/tests/UI/Editor/integration/layout/CMakeLists.txt b/tests/UI/Editor/integration/layout/CMakeLists.txt index 36219ca9..e49fcbaf 100644 --- a/tests/UI/Editor/integration/layout/CMakeLists.txt +++ b/tests/UI/Editor/integration/layout/CMakeLists.txt @@ -1,6 +1,10 @@ add_subdirectory(splitter_resize) +add_subdirectory(tab_strip_selection) +add_subdirectory(workspace_compose) add_custom_target(editor_ui_layout_integration_tests DEPENDS editor_ui_layout_splitter_resize_validation + editor_ui_layout_tab_strip_selection_validation + editor_ui_layout_workspace_compose_validation ) diff --git a/tests/UI/Editor/integration/layout/tab_strip_selection/CMakeLists.txt b/tests/UI/Editor/integration/layout/tab_strip_selection/CMakeLists.txt new file mode 100644 index 00000000..20a96b76 --- /dev/null +++ b/tests/UI/Editor/integration/layout/tab_strip_selection/CMakeLists.txt @@ -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$<$: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) diff --git a/tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui b/tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui new file mode 100644 index 00000000..fab8a51c --- /dev/null +++ b/tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/UI/Editor/integration/layout/tab_strip_selection/captures/.gitkeep b/tests/UI/Editor/integration/layout/tab_strip_selection/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/layout/tab_strip_selection/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/layout/tab_strip_selection/main.cpp b/tests/UI/Editor/integration/layout/tab_strip_selection/main.cpp new file mode 100644 index 00000000..4a390fe5 --- /dev/null +++ b/tests/UI/Editor/integration/layout/tab_strip_selection/main.cpp @@ -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"); +} diff --git a/tests/UI/Editor/integration/layout/workspace_compose/CMakeLists.txt b/tests/UI/Editor/integration/layout/workspace_compose/CMakeLists.txt new file mode 100644 index 00000000..57be85b4 --- /dev/null +++ b/tests/UI/Editor/integration/layout/workspace_compose/CMakeLists.txt @@ -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$<$: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) diff --git a/tests/UI/Editor/integration/layout/workspace_compose/View.xcui b/tests/UI/Editor/integration/layout/workspace_compose/View.xcui new file mode 100644 index 00000000..c8dec689 --- /dev/null +++ b/tests/UI/Editor/integration/layout/workspace_compose/View.xcui @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/UI/Editor/integration/layout/workspace_compose/captures/.gitkeep b/tests/UI/Editor/integration/layout/workspace_compose/captures/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/UI/Editor/integration/layout/workspace_compose/captures/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/UI/Editor/integration/layout/workspace_compose/main.cpp b/tests/UI/Editor/integration/layout/workspace_compose/main.cpp new file mode 100644 index 00000000..92f12c75 --- /dev/null +++ b/tests/UI/Editor/integration/layout/workspace_compose/main.cpp @@ -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"); +} diff --git a/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp b/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp index 1c6ea15c..6ab54ec6 100644 --- a/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp +++ b/tests/UI/Editor/integration/shared/src/EditorValidationScenario.cpp @@ -24,8 +24,8 @@ fs::path RepoRelative(const char* relativePath) { return (RepoRootPath() / relativePath).lexically_normal(); } -const std::array& GetEditorValidationScenarios() { - static const std::array scenarios = { { +const std::array& GetEditorValidationScenarios() { + static const std::array scenarios = { { { "editor.input.keyboard_focus", UIValidationDomain::Editor, @@ -61,6 +61,24 @@ const std::array& GetEditorValidationScenarios() { RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/View.xcui"), RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"), RepoRelative("tests/UI/Editor/integration/layout/splitter_resize/captures") + }, + { + "editor.layout.tab_strip_selection", + UIValidationDomain::Editor, + "layout", + "Editor Layout | TabStrip Selection", + RepoRelative("tests/UI/Editor/integration/layout/tab_strip_selection/View.xcui"), + RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"), + RepoRelative("tests/UI/Editor/integration/layout/tab_strip_selection/captures") + }, + { + "editor.layout.workspace_compose", + UIValidationDomain::Editor, + "layout", + "Editor Layout | Workspace Compose", + RepoRelative("tests/UI/Editor/integration/layout/workspace_compose/View.xcui"), + RepoRelative("tests/UI/Editor/integration/shared/themes/editor_validation.xctheme"), + RepoRelative("tests/UI/Editor/integration/layout/workspace_compose/captures") } } }; return scenarios; diff --git a/tests/UI/Editor/unit/CMakeLists.txt b/tests/UI/Editor/unit/CMakeLists.txt index 3b87c4e8..3c48f354 100644 --- a/tests/UI/Editor/unit/CMakeLists.txt +++ b/tests/UI/Editor/unit/CMakeLists.txt @@ -2,6 +2,7 @@ set(EDITOR_UI_UNIT_TEST_SOURCES test_input_modifier_tracker.cpp test_editor_validation_registry.cpp test_structured_editor_shell.cpp + test_ui_editor_workspace_model.cpp # Migration bridge: editor-facing XCUI primitive tests still reuse the # legacy source location until they are relocated under tests/UI/Editor/unit. ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_editor_collection_primitives.cpp diff --git a/tests/UI/Editor/unit/test_editor_validation_registry.cpp b/tests/UI/Editor/unit/test_editor_validation_registry.cpp index 08eeab57..a6a762cc 100644 --- a/tests/UI/Editor/unit/test_editor_validation_registry.cpp +++ b/tests/UI/Editor/unit/test_editor_validation_registry.cpp @@ -17,19 +17,27 @@ TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExisti const auto* keyboardScenario = FindEditorValidationScenario("editor.input.keyboard_focus"); const auto* shortcutScenario = FindEditorValidationScenario("editor.input.shortcut_scope"); const auto* splitterScenario = FindEditorValidationScenario("editor.layout.splitter_resize"); + const auto* tabStripScenario = FindEditorValidationScenario("editor.layout.tab_strip_selection"); + const auto* workspaceScenario = FindEditorValidationScenario("editor.layout.workspace_compose"); ASSERT_NE(pointerScenario, nullptr); ASSERT_NE(keyboardScenario, nullptr); ASSERT_NE(shortcutScenario, nullptr); ASSERT_NE(splitterScenario, nullptr); + ASSERT_NE(tabStripScenario, nullptr); + ASSERT_NE(workspaceScenario, nullptr); EXPECT_EQ(pointerScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(keyboardScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(shortcutScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(splitterScenario->domain, UIValidationDomain::Editor); + EXPECT_EQ(tabStripScenario->domain, UIValidationDomain::Editor); + EXPECT_EQ(workspaceScenario->domain, UIValidationDomain::Editor); EXPECT_EQ(pointerScenario->categoryId, "input"); EXPECT_EQ(keyboardScenario->categoryId, "input"); EXPECT_EQ(shortcutScenario->categoryId, "input"); EXPECT_EQ(splitterScenario->categoryId, "layout"); + EXPECT_EQ(tabStripScenario->categoryId, "layout"); + EXPECT_EQ(workspaceScenario->categoryId, "layout"); EXPECT_TRUE(std::filesystem::exists(pointerScenario->documentPath)); EXPECT_TRUE(std::filesystem::exists(pointerScenario->themePath)); EXPECT_TRUE(std::filesystem::exists(keyboardScenario->documentPath)); @@ -38,6 +46,10 @@ TEST(EditorValidationRegistryTest, KnownEditorValidationScenariosResolveToExisti EXPECT_TRUE(std::filesystem::exists(shortcutScenario->themePath)); EXPECT_TRUE(std::filesystem::exists(splitterScenario->documentPath)); EXPECT_TRUE(std::filesystem::exists(splitterScenario->themePath)); + EXPECT_TRUE(std::filesystem::exists(tabStripScenario->documentPath)); + EXPECT_TRUE(std::filesystem::exists(tabStripScenario->themePath)); + EXPECT_TRUE(std::filesystem::exists(workspaceScenario->documentPath)); + EXPECT_TRUE(std::filesystem::exists(workspaceScenario->themePath)); } TEST(EditorValidationRegistryTest, DefaultScenarioPointsToKeyboardFocusBatch) { diff --git a/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp b/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp new file mode 100644 index 00000000..8f4412dc --- /dev/null +++ b/tests/UI/Editor/unit/test_ui_editor_workspace_model.cpp @@ -0,0 +1,143 @@ +#include + +#include + +#include +#include +#include + +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 CollectVisiblePanelIds(const UIEditorWorkspaceModel& workspace) { + const auto panels = CollectUIEditorWorkspaceVisiblePanels(workspace); + std::vector 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); +} diff --git a/tests/UI/Runtime/unit/CMakeLists.txt b/tests/UI/Runtime/unit/CMakeLists.txt index 92d7f8f6..003f003d 100644 --- a/tests/UI/Runtime/unit/CMakeLists.txt +++ b/tests/UI/Runtime/unit/CMakeLists.txt @@ -1,12 +1,14 @@ set(RUNTIME_UI_TEST_SOURCES ${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_shortcut_scope.cpp ${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_splitter_validation.cpp + ${CMAKE_SOURCE_DIR}/tests/UI/Runtime/unit/test_ui_runtime_tab_strip.cpp ${CMAKE_SOURCE_DIR}/tests/Core/UI/test_ui_runtime.cpp ) add_executable(runtime_ui_tests ${RUNTIME_UI_TEST_SOURCES}) if(MSVC) + target_compile_options(runtime_ui_tests PRIVATE /FS) set_target_properties(runtime_ui_tests PROPERTIES LINK_FLAGS "/NODEFAULTLIB:libcpmt.lib /NODEFAULTLIB:libcmt.lib" ) diff --git a/tests/UI/Runtime/unit/test_ui_runtime_tab_strip.cpp b/tests/UI/Runtime/unit/test_ui_runtime_tab_strip.cpp new file mode 100644 index 00000000..0ea64458 --- /dev/null +++ b/tests/UI/Runtime/unit/test_ui_runtime_tab_strip.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include + +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 + "\n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\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(keyCode); + return event; +} + +} // namespace + +TEST(UIRuntimeTabStripValidationTest, EmptyTabStripProducesExplicitFrameError) { + TempFileScope viewFile( + "xcui_runtime_invalid_tab_strip", + ".xcui", + "\n" + " \n" + " \n" + " \n" + "\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); +} diff --git a/tests/UI/TEST_SPEC.md b/tests/UI/TEST_SPEC.md index c8c22d53..c3f4d78a 100644 --- a/tests/UI/TEST_SPEC.md +++ b/tests/UI/TEST_SPEC.md @@ -121,6 +121,8 @@ Runtime 的集成测试结构与 Editor 保持同一规范,但宿主职责必 - `editor.input.pointer_states` - `editor.input.shortcut_scope` - `editor.layout.splitter_resize` +- `editor.layout.tab_strip_selection` +- `editor.layout.workspace_compose` 这些场景只用于验证 XCUI 模块能力,不代表开始复刻完整 editor 面板。