Build XCEditor menu and status shell widgets

This commit is contained in:
2026-04-07 03:51:26 +08:00
parent 5f9f3386ab
commit 8eeb7af56e
25 changed files with 3708 additions and 106 deletions

View File

@@ -6,11 +6,14 @@ set(EDITOR_UI_UNIT_TEST_SOURCES
test_ui_editor_command_registry.cpp
test_ui_editor_menu_model.cpp
test_ui_editor_menu_session.cpp
test_ui_editor_menu_bar.cpp
test_ui_editor_menu_popup.cpp
test_ui_editor_panel_registry.cpp
test_ui_editor_collection_primitives.cpp
test_ui_editor_dock_host.cpp
test_ui_editor_panel_chrome.cpp
test_ui_editor_panel_frame.cpp
test_ui_editor_status_bar.cpp
test_ui_editor_tab_strip.cpp
test_ui_editor_shortcut_manager.cpp
test_ui_editor_workspace_controller.cpp

View File

@@ -0,0 +1,117 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEditor/Widgets/UIEditorMenuBar.h>
namespace {
using XCEngine::UI::UIDrawCommandType;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuBarBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuBarForeground;
using XCEngine::UI::Editor::Widgets::BuildUIEditorMenuBarLayout;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorMenuBar;
using XCEngine::UI::Editor::Widgets::ResolveUIEditorMenuBarDesiredButtonWidth;
using XCEngine::UI::Editor::Widgets::UIEditorMenuBarHitTargetKind;
using XCEngine::UI::Editor::Widgets::UIEditorMenuBarInvalidIndex;
using XCEngine::UI::Editor::Widgets::UIEditorMenuBarItem;
using XCEngine::UI::Editor::Widgets::UIEditorMenuBarState;
TEST(UIEditorMenuBarTest, DesiredWidthUsesLabelEstimateAndHorizontalPadding) {
XCEngine::UI::Editor::Widgets::UIEditorMenuBarMetrics metrics = {};
metrics.estimatedGlyphWidth = 8.0f;
metrics.buttonPaddingX = 12.0f;
EXPECT_FLOAT_EQ(
ResolveUIEditorMenuBarDesiredButtonWidth(
UIEditorMenuBarItem{ "file", "File", true, 0.0f },
metrics),
56.0f);
EXPECT_FLOAT_EQ(
ResolveUIEditorMenuBarDesiredButtonWidth(
UIEditorMenuBarItem{ "window", "Window", true, 50.0f },
metrics),
74.0f);
}
TEST(UIEditorMenuBarTest, LayoutBuildsHorizontalButtonsInsideBarInsets) {
XCEngine::UI::Editor::Widgets::UIEditorMenuBarMetrics metrics = {};
metrics.horizontalInset = 8.0f;
metrics.verticalInset = 3.0f;
metrics.buttonGap = 4.0f;
metrics.buttonPaddingX = 10.0f;
metrics.estimatedGlyphWidth = 8.0f;
const std::vector<UIEditorMenuBarItem> items = {
{ "file", "File", true, 0.0f },
{ "window", "Window", true, 32.0f }
};
const auto layout =
BuildUIEditorMenuBarLayout(UIRect(10.0f, 20.0f, 240.0f, 32.0f), items, metrics);
EXPECT_FLOAT_EQ(layout.contentRect.x, 18.0f);
EXPECT_FLOAT_EQ(layout.contentRect.y, 23.0f);
EXPECT_FLOAT_EQ(layout.contentRect.width, 224.0f);
EXPECT_FLOAT_EQ(layout.contentRect.height, 26.0f);
ASSERT_EQ(layout.buttonRects.size(), 2u);
EXPECT_FLOAT_EQ(layout.buttonRects[0].x, 18.0f);
EXPECT_FLOAT_EQ(layout.buttonRects[0].width, 52.0f);
EXPECT_FLOAT_EQ(layout.buttonRects[1].x, 74.0f);
EXPECT_FLOAT_EQ(layout.buttonRects[1].width, 52.0f);
}
TEST(UIEditorMenuBarTest, HitTestResolvesButtonBeforeBarBackground) {
const std::vector<UIEditorMenuBarItem> items = {
{ "file", "File", true, 0.0f },
{ "window", "Window", true, 32.0f }
};
const auto layout =
BuildUIEditorMenuBarLayout(UIRect(10.0f, 20.0f, 240.0f, 32.0f), items);
const auto buttonHit = HitTestUIEditorMenuBar(layout, UIPoint(30.0f, 32.0f));
EXPECT_EQ(buttonHit.kind, UIEditorMenuBarHitTargetKind::Button);
EXPECT_EQ(buttonHit.index, 0u);
const auto backgroundHit = HitTestUIEditorMenuBar(layout, UIPoint(220.0f, 32.0f));
EXPECT_EQ(backgroundHit.kind, UIEditorMenuBarHitTargetKind::BarBackground);
EXPECT_EQ(backgroundHit.index, UIEditorMenuBarInvalidIndex);
}
TEST(UIEditorMenuBarTest, BackgroundAndForegroundEmitStableCommands) {
const std::vector<UIEditorMenuBarItem> items = {
{ "file", "File", true, 0.0f },
{ "window", "Window", false, 32.0f }
};
UIEditorMenuBarState state = {};
state.openIndex = 0u;
state.hoveredIndex = 1u;
state.focused = true;
const auto layout =
BuildUIEditorMenuBarLayout(UIRect(10.0f, 20.0f, 240.0f, 32.0f), items);
UIDrawList background("MenuBarBackground");
AppendUIEditorMenuBarBackground(background, layout, items, state);
ASSERT_EQ(background.GetCommandCount(), 6u);
const auto& backgroundCommands = background.GetCommands();
EXPECT_EQ(backgroundCommands[0].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[1].type, UIDrawCommandType::RectOutline);
EXPECT_EQ(backgroundCommands[2].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[5].type, UIDrawCommandType::RectOutline);
UIDrawList foreground("MenuBarForeground");
AppendUIEditorMenuBarForeground(foreground, layout, items, state);
ASSERT_EQ(foreground.GetCommandCount(), 6u);
const auto& foregroundCommands = foreground.GetCommands();
EXPECT_EQ(foregroundCommands[0].type, UIDrawCommandType::PushClipRect);
EXPECT_EQ(foregroundCommands[1].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[1].text, "File");
EXPECT_EQ(foregroundCommands[4].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[4].text, "Window");
}
} // namespace

View File

@@ -0,0 +1,136 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEditor/Widgets/UIEditorMenuPopup.h>
namespace {
using XCEngine::UI::UIDrawCommandType;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::Editor::UIEditorMenuItemKind;
using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuPopupBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorMenuPopupForeground;
using XCEngine::UI::Editor::Widgets::BuildUIEditorMenuPopupLayout;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorMenuPopup;
using XCEngine::UI::Editor::Widgets::MeasureUIEditorMenuPopupHeight;
using XCEngine::UI::Editor::Widgets::ResolveUIEditorMenuPopupDesiredWidth;
using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupHitTargetKind;
using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupInvalidIndex;
using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupItem;
using XCEngine::UI::Editor::Widgets::UIEditorMenuPopupState;
std::vector<UIEditorMenuPopupItem> BuildItems() {
return {
{ "show-inspector", UIEditorMenuItemKind::Command, "Show Inspector", "Ctrl+I", true, true, false, 0.0f, 0.0f },
{ "separator-1", UIEditorMenuItemKind::Separator, {}, {}, false, false, false, 0.0f, 0.0f },
{ "layout", UIEditorMenuItemKind::Submenu, "Layout", {}, true, false, true, 0.0f, 0.0f },
{ "close", UIEditorMenuItemKind::Command, "Close", "Ctrl+W", false, false, false, 0.0f, 0.0f }
};
}
} // namespace
TEST(UIEditorMenuPopupTest, DesiredWidthAndHeightUseLabelShortcutAndSeparatorMetrics) {
XCEngine::UI::Editor::Widgets::UIEditorMenuPopupMetrics metrics = {};
metrics.contentPaddingX = 8.0f;
metrics.contentPaddingY = 5.0f;
metrics.labelInsetX = 12.0f;
metrics.checkColumnWidth = 20.0f;
metrics.shortcutGap = 18.0f;
metrics.shortcutInsetRight = 22.0f;
metrics.submenuIndicatorWidth = 14.0f;
metrics.estimatedGlyphWidth = 8.0f;
metrics.itemHeight = 30.0f;
metrics.separatorHeight = 10.0f;
EXPECT_FLOAT_EQ(
ResolveUIEditorMenuPopupDesiredWidth(BuildItems(), metrics),
248.0f);
EXPECT_FLOAT_EQ(
MeasureUIEditorMenuPopupHeight(BuildItems(), metrics),
110.0f);
}
TEST(UIEditorMenuPopupTest, LayoutBuildsStackedRowsAndSeparatorSlots) {
XCEngine::UI::Editor::Widgets::UIEditorMenuPopupMetrics metrics = {};
metrics.contentPaddingX = 6.0f;
metrics.contentPaddingY = 4.0f;
metrics.itemHeight = 26.0f;
metrics.separatorHeight = 8.0f;
const auto layout = BuildUIEditorMenuPopupLayout(
UIRect(100.0f, 50.0f, 220.0f, 94.0f),
BuildItems(),
metrics);
EXPECT_FLOAT_EQ(layout.contentRect.x, 106.0f);
EXPECT_FLOAT_EQ(layout.contentRect.y, 54.0f);
EXPECT_FLOAT_EQ(layout.contentRect.width, 208.0f);
EXPECT_FLOAT_EQ(layout.contentRect.height, 86.0f);
ASSERT_EQ(layout.itemRects.size(), 4u);
EXPECT_FLOAT_EQ(layout.itemRects[0].y, 54.0f);
EXPECT_FLOAT_EQ(layout.itemRects[0].height, 26.0f);
EXPECT_FLOAT_EQ(layout.itemRects[1].y, 80.0f);
EXPECT_FLOAT_EQ(layout.itemRects[1].height, 8.0f);
EXPECT_FLOAT_EQ(layout.itemRects[2].y, 88.0f);
EXPECT_FLOAT_EQ(layout.itemRects[3].y, 114.0f);
}
TEST(UIEditorMenuPopupTest, HitTestIgnoresSeparatorsAndFallsBackToPopupSurface) {
const auto items = BuildItems();
const auto layout = BuildUIEditorMenuPopupLayout(
UIRect(100.0f, 50.0f, 220.0f, 118.0f),
items);
const auto itemHit = HitTestUIEditorMenuPopup(layout, items, UIPoint(130.0f, 66.0f));
EXPECT_EQ(itemHit.kind, UIEditorMenuPopupHitTargetKind::Item);
EXPECT_EQ(itemHit.index, 0u);
const auto separatorHit = HitTestUIEditorMenuPopup(layout, items, UIPoint(130.0f, 88.0f));
EXPECT_EQ(separatorHit.kind, UIEditorMenuPopupHitTargetKind::PopupSurface);
EXPECT_EQ(separatorHit.index, UIEditorMenuPopupInvalidIndex);
const auto outsideHit = HitTestUIEditorMenuPopup(layout, items, UIPoint(20.0f, 20.0f));
EXPECT_EQ(outsideHit.kind, UIEditorMenuPopupHitTargetKind::None);
}
TEST(UIEditorMenuPopupTest, BackgroundAndForegroundEmitStableCommands) {
const auto items = BuildItems();
UIEditorMenuPopupState state = {};
state.hoveredIndex = 0u;
state.submenuOpenIndex = 2u;
const auto layout = BuildUIEditorMenuPopupLayout(
UIRect(100.0f, 50.0f, 220.0f, 118.0f),
items);
UIDrawList background("MenuPopupBackground");
AppendUIEditorMenuPopupBackground(background, layout, items, state);
ASSERT_EQ(background.GetCommandCount(), 5u);
const auto& backgroundCommands = background.GetCommands();
EXPECT_EQ(backgroundCommands[0].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[1].type, UIDrawCommandType::RectOutline);
EXPECT_EQ(backgroundCommands[2].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(backgroundCommands[4].type, UIDrawCommandType::FilledRect);
UIDrawList foreground("MenuPopupForeground");
AppendUIEditorMenuPopupForeground(foreground, layout, items, state);
ASSERT_EQ(foreground.GetCommandCount(), 13u);
const auto& foregroundCommands = foreground.GetCommands();
EXPECT_EQ(foregroundCommands[0].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[0].text, "*");
EXPECT_EQ(foregroundCommands[2].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[2].text, "Show Inspector");
EXPECT_EQ(foregroundCommands[4].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[4].text, "Ctrl+I");
EXPECT_EQ(foregroundCommands[6].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[6].text, "Layout");
EXPECT_EQ(foregroundCommands[8].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[8].text, ">");
EXPECT_EQ(foregroundCommands[10].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[10].text, "Close");
EXPECT_EQ(foregroundCommands[12].type, UIDrawCommandType::Text);
EXPECT_EQ(foregroundCommands[12].text, "Ctrl+W");
}

View File

@@ -73,6 +73,45 @@ TEST(UIEditorMenuSessionTest, OpenMenuBarRootTracksActiveMenuAndRootPopup) {
EXPECT_TRUE(session.GetOpenSubmenuItemIds().empty());
}
TEST(UIEditorMenuSessionTest, OpenRootMenuSupportsGenericRootPopupEntry) {
UIEditorMenuSession session = {};
const auto result = session.OpenRootMenu(
"context.scene",
MakePopup("menu.context.scene.root", "", UIInputPath{500u, 510u}, UIInputPath{5u, 6u}));
EXPECT_TRUE(result.changed);
EXPECT_EQ(result.openRootMenuId, "context.scene");
EXPECT_EQ(result.openedPopupId, "menu.context.scene.root");
EXPECT_TRUE(result.closedPopupIds.empty());
EXPECT_TRUE(session.IsMenuOpen("context.scene"));
EXPECT_TRUE(session.IsPopupOpen("menu.context.scene.root"));
ASSERT_EQ(session.GetPopupOverlayModel().GetPopupCount(), 1u);
ASSERT_EQ(session.GetPopupStates().size(), 1u);
EXPECT_TRUE(session.GetPopupStates().front().IsRootPopup());
}
TEST(UIEditorMenuSessionTest, OpenRootMenuRepositionsPopupWhenAnchorChanges) {
UIEditorMenuSession session = {};
ASSERT_TRUE(session.OpenRootMenu(
"context.scene",
MakePopup("menu.context.scene.root", "", UIInputPath{500u, 510u}, UIInputPath{5u, 6u}))
.changed);
UIPopupOverlayEntry movedPopup =
MakePopup("menu.context.scene.root", "", UIInputPath{500u, 510u}, UIInputPath{5u, 6u});
movedPopup.anchorRect = { 320.0f, 180.0f, 1.0f, 1.0f };
const auto result = session.OpenRootMenu("context.scene", movedPopup);
EXPECT_TRUE(result.changed);
EXPECT_EQ(result.openRootMenuId, "context.scene");
EXPECT_EQ(result.openedPopupId, "menu.context.scene.root");
ASSERT_NE(session.GetPopupOverlayModel().GetRootPopup(), nullptr);
EXPECT_FLOAT_EQ(session.GetPopupOverlayModel().GetRootPopup()->anchorRect.x, 320.0f);
EXPECT_FLOAT_EQ(session.GetPopupOverlayModel().GetRootPopup()->anchorRect.y, 180.0f);
}
TEST(UIEditorMenuSessionTest, HoverMenuBarRootReplacesOpenRootAndClearsSubmenuPath) {
UIEditorMenuSession session = {};
ASSERT_TRUE(session.OpenMenuBarRoot(

View File

@@ -0,0 +1,120 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEditor/Widgets/UIEditorStatusBar.h>
namespace {
using XCEngine::UI::UIColor;
using XCEngine::UI::UIDrawCommandType;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::Editor::Widgets::AppendUIEditorStatusBarBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorStatusBarForeground;
using XCEngine::UI::Editor::Widgets::BuildUIEditorStatusBarLayout;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorStatusBar;
using XCEngine::UI::Editor::Widgets::ResolveUIEditorStatusBarDesiredSegmentWidth;
using XCEngine::UI::Editor::Widgets::ResolveUIEditorStatusBarTextColor;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarHitTargetKind;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarInvalidIndex;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarLayout;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarPalette;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSegment;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarSlot;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarState;
using XCEngine::UI::Editor::Widgets::UIEditorStatusBarTextTone;
void ExpectColorEq(const UIColor& actual, const UIColor& expected) {
EXPECT_FLOAT_EQ(actual.r, expected.r);
EXPECT_FLOAT_EQ(actual.g, expected.g);
EXPECT_FLOAT_EQ(actual.b, expected.b);
EXPECT_FLOAT_EQ(actual.a, expected.a);
}
std::vector<UIEditorStatusBarSegment> BuildSegments() {
return {
{ "scene", "Scene: Main", UIEditorStatusBarSlot::Leading, UIEditorStatusBarTextTone::Primary, true, true, 92.0f },
{ "selection", "Selection: Camera", UIEditorStatusBarSlot::Leading, UIEditorStatusBarTextTone::Accent, true, false, 138.0f },
{ "frame", "16.7 ms", UIEditorStatusBarSlot::Trailing, UIEditorStatusBarTextTone::Muted, true, true, 64.0f },
{ "gpu", "GPU Ready", UIEditorStatusBarSlot::Trailing, UIEditorStatusBarTextTone::Primary, true, false, 86.0f }
};
}
TEST(UIEditorStatusBarTest, DesiredWidthUsesExplicitValueBeforeLabelEstimate) {
UIEditorStatusBarSegment explicitWidth = {};
explicitWidth.label = "Scene";
explicitWidth.desiredWidth = 84.0f;
UIEditorStatusBarSegment inferredWidth = {};
inferredWidth.label = "Scene";
EXPECT_FLOAT_EQ(ResolveUIEditorStatusBarDesiredSegmentWidth(explicitWidth), 84.0f);
EXPECT_FLOAT_EQ(ResolveUIEditorStatusBarDesiredSegmentWidth(inferredWidth), 55.0f);
}
TEST(UIEditorStatusBarTest, LayoutBuildsLeadingAndTrailingSlotsWithSeparators) {
const UIEditorStatusBarLayout layout =
BuildUIEditorStatusBarLayout(UIRect(20.0f, 40.0f, 520.0f, 28.0f), BuildSegments());
EXPECT_FLOAT_EQ(layout.leadingSlotRect.x, 30.0f);
EXPECT_FLOAT_EQ(layout.leadingSlotRect.width, 235.0f);
EXPECT_FLOAT_EQ(layout.trailingSlotRect.x, 371.0f);
EXPECT_FLOAT_EQ(layout.trailingSlotRect.width, 159.0f);
EXPECT_FLOAT_EQ(layout.segmentRects[0].x, 30.0f);
EXPECT_FLOAT_EQ(layout.segmentRects[1].x, 127.0f);
EXPECT_FLOAT_EQ(layout.segmentRects[2].x, 371.0f);
EXPECT_FLOAT_EQ(layout.segmentRects[3].x, 444.0f);
EXPECT_FLOAT_EQ(layout.separatorRects[0].x, 122.0f);
EXPECT_FLOAT_EQ(layout.separatorRects[2].x, 439.0f);
EXPECT_FLOAT_EQ(layout.separatorRects[1].width, 0.0f);
}
TEST(UIEditorStatusBarTest, HitTestReturnsSeparatorThenSegmentThenBackground) {
const UIEditorStatusBarLayout layout =
BuildUIEditorStatusBarLayout(UIRect(20.0f, 40.0f, 520.0f, 28.0f), BuildSegments());
auto hit = HitTestUIEditorStatusBar(layout, UIPoint(122.5f, 54.0f));
EXPECT_EQ(hit.kind, UIEditorStatusBarHitTargetKind::Separator);
EXPECT_EQ(hit.index, 0u);
hit = HitTestUIEditorStatusBar(layout, UIPoint(150.0f, 54.0f));
EXPECT_EQ(hit.kind, UIEditorStatusBarHitTargetKind::Segment);
EXPECT_EQ(hit.index, 1u);
hit = HitTestUIEditorStatusBar(layout, UIPoint(300.0f, 54.0f));
EXPECT_EQ(hit.kind, UIEditorStatusBarHitTargetKind::Background);
EXPECT_EQ(hit.index, UIEditorStatusBarInvalidIndex);
}
TEST(UIEditorStatusBarTest, BackgroundAndForegroundEmitStableChromeAndTextCommands) {
const auto segments = BuildSegments();
UIEditorStatusBarState state = {};
state.hoveredIndex = 0u;
state.activeIndex = 1u;
state.focused = true;
const UIEditorStatusBarPalette palette = {};
const UIEditorStatusBarLayout layout =
BuildUIEditorStatusBarLayout(UIRect(12.0f, 16.0f, 520.0f, 28.0f), segments);
UIDrawList background("StatusBarBackground");
AppendUIEditorStatusBarBackground(background, layout, segments, state, palette);
ASSERT_EQ(background.GetCommandCount(), 8u);
EXPECT_EQ(background.GetCommands()[0].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(background.GetCommands()[1].type, UIDrawCommandType::RectOutline);
ExpectColorEq(background.GetCommands()[1].color, palette.focusedBorderColor);
UIDrawList foreground("StatusBarForeground");
AppendUIEditorStatusBarForeground(foreground, layout, segments, state, palette);
ASSERT_EQ(foreground.GetCommandCount(), 4u);
EXPECT_EQ(foreground.GetCommands()[0].text, "Scene: Main");
EXPECT_EQ(foreground.GetCommands()[1].text, "Selection: Camera");
ExpectColorEq(
foreground.GetCommands()[1].color,
ResolveUIEditorStatusBarTextColor(UIEditorStatusBarTextTone::Accent, palette));
}
} // namespace