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

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

View File

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

View File

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

View File

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

View File

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