Add editor tree view widget contract

This commit is contained in:
2026-04-07 14:41:01 +08:00
parent 442565f176
commit 0308be1483
14 changed files with 1916 additions and 0 deletions

View File

@@ -20,6 +20,8 @@ set(EDITOR_UI_UNIT_TEST_SOURCES
test_ui_editor_panel_frame.cpp
test_ui_editor_status_bar.cpp
test_ui_editor_tab_strip.cpp
test_ui_editor_tree_view.cpp
test_ui_editor_tree_view_interaction.cpp
test_ui_editor_viewport_input_bridge.cpp
test_ui_editor_viewport_shell.cpp
test_ui_editor_viewport_slot.cpp

View File

@@ -0,0 +1,187 @@
#include <gtest/gtest.h>
#include <XCEngine/UI/DrawData.h>
#include <XCEngine/UI/Widgets/UIExpansionModel.h>
#include <XCEngine/UI/Widgets/UISelectionModel.h>
#include <XCEditor/Widgets/UIEditorTreeView.h>
namespace {
using XCEngine::UI::UIDrawCommandType;
using XCEngine::UI::UIDrawList;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIRect;
using XCEngine::UI::Widgets::UIExpansionModel;
using XCEngine::UI::Widgets::UISelectionModel;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewBackground;
using XCEngine::UI::Editor::Widgets::AppendUIEditorTreeViewForeground;
using XCEngine::UI::Editor::Widgets::BuildUIEditorTreeViewLayout;
using XCEngine::UI::Editor::Widgets::CollectUIEditorTreeViewVisibleItemIndices;
using XCEngine::UI::Editor::Widgets::DoesUIEditorTreeViewItemHaveChildren;
using XCEngine::UI::Editor::Widgets::FindUIEditorTreeViewFirstVisibleChildItemIndex;
using XCEngine::UI::Editor::Widgets::FindUIEditorTreeViewItemIndex;
using XCEngine::UI::Editor::Widgets::FindUIEditorTreeViewParentItemIndex;
using XCEngine::UI::Editor::Widgets::HitTestUIEditorTreeView;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTargetKind;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewItem;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewInvalidIndex;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewLayout;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewMetrics;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewState;
bool ContainsTextCommand(const UIDrawList& drawList, std::string_view text) {
for (const auto& command : drawList.GetCommands()) {
if (command.type == UIDrawCommandType::Text && command.text == text) {
return true;
}
}
return false;
}
std::vector<UIEditorTreeViewItem> BuildTreeItems() {
return {
{ "scene", "Scene", 0u, false, 0.0f },
{ "camera", "Camera", 1u, true, 0.0f },
{ "lights", "Lights", 1u, false, 0.0f },
{ "sun", "Directional Light", 2u, true, 0.0f },
{ "ui", "UI Root", 0u, false, 0.0f },
{ "canvas", "Canvas", 1u, true, 0.0f }
};
}
TEST(UIEditorTreeViewTest, ChildDetectionUsesFlatHierarchyDepthRules) {
const std::vector<UIEditorTreeViewItem> items = BuildTreeItems();
EXPECT_TRUE(DoesUIEditorTreeViewItemHaveChildren(items, 0u));
EXPECT_FALSE(DoesUIEditorTreeViewItemHaveChildren(items, 1u));
EXPECT_TRUE(DoesUIEditorTreeViewItemHaveChildren(items, 2u));
EXPECT_TRUE(DoesUIEditorTreeViewItemHaveChildren(items, 4u));
EXPECT_EQ(FindUIEditorTreeViewItemIndex(items, "lights"), 2u);
EXPECT_EQ(FindUIEditorTreeViewParentItemIndex(items, 0u), UIEditorTreeViewInvalidIndex);
EXPECT_EQ(FindUIEditorTreeViewParentItemIndex(items, 1u), 0u);
EXPECT_EQ(FindUIEditorTreeViewParentItemIndex(items, 3u), 2u);
}
TEST(UIEditorTreeViewTest, VisibleItemsFollowExpansionModel) {
const std::vector<UIEditorTreeViewItem> items = BuildTreeItems();
UIExpansionModel expansionModel = {};
EXPECT_EQ(
FindUIEditorTreeViewFirstVisibleChildItemIndex(items, expansionModel, 0u),
UIEditorTreeViewInvalidIndex);
EXPECT_EQ(
CollectUIEditorTreeViewVisibleItemIndices(items, expansionModel),
std::vector<std::size_t>({ 0u, 4u }));
expansionModel.Expand("scene");
EXPECT_EQ(
FindUIEditorTreeViewFirstVisibleChildItemIndex(items, expansionModel, 0u),
1u);
EXPECT_EQ(
CollectUIEditorTreeViewVisibleItemIndices(items, expansionModel),
std::vector<std::size_t>({ 0u, 1u, 2u, 4u }));
expansionModel.Expand("lights");
expansionModel.Expand("ui");
EXPECT_EQ(
FindUIEditorTreeViewFirstVisibleChildItemIndex(items, expansionModel, 2u),
3u);
EXPECT_EQ(
CollectUIEditorTreeViewVisibleItemIndices(items, expansionModel),
std::vector<std::size_t>({ 0u, 1u, 2u, 3u, 4u, 5u }));
}
TEST(UIEditorTreeViewTest, LayoutBuildsIndentedDisclosureAndLabelRects) {
const std::vector<UIEditorTreeViewItem> items = BuildTreeItems();
UIExpansionModel expansionModel = {};
expansionModel.Expand("scene");
UIEditorTreeViewMetrics metrics = {};
metrics.rowHeight = 24.0f;
metrics.rowGap = 4.0f;
metrics.horizontalPadding = 10.0f;
metrics.indentWidth = 20.0f;
metrics.disclosureExtent = 10.0f;
metrics.disclosureLabelGap = 6.0f;
const UIEditorTreeViewLayout layout =
BuildUIEditorTreeViewLayout(UIRect(20.0f, 30.0f, 280.0f, 240.0f), items, expansionModel, metrics);
ASSERT_EQ(layout.visibleItemIndices.size(), 4u);
EXPECT_EQ(layout.visibleItemIndices[0], 0u);
EXPECT_EQ(layout.visibleItemIndices[1], 1u);
EXPECT_EQ(layout.visibleItemIndices[2], 2u);
EXPECT_EQ(layout.visibleItemIndices[3], 4u);
EXPECT_FLOAT_EQ(layout.rowRects[0].x, 20.0f);
EXPECT_FLOAT_EQ(layout.rowRects[0].y, 30.0f);
EXPECT_FLOAT_EQ(layout.rowRects[0].height, 24.0f);
EXPECT_FLOAT_EQ(layout.disclosureRects[0].x, 30.0f);
EXPECT_FLOAT_EQ(layout.disclosureRects[1].x, 50.0f);
EXPECT_FLOAT_EQ(layout.disclosureRects[2].x, 50.0f);
EXPECT_FLOAT_EQ(layout.labelRects[0].x, 46.0f);
EXPECT_FLOAT_EQ(layout.labelRects[1].x, 66.0f);
EXPECT_TRUE(layout.itemHasChildren[0]);
EXPECT_FALSE(layout.itemHasChildren[1]);
EXPECT_TRUE(layout.itemExpanded[0]);
EXPECT_FALSE(layout.itemExpanded[2]);
}
TEST(UIEditorTreeViewTest, HitTestPrioritizesDisclosureBeforeRow) {
const std::vector<UIEditorTreeViewItem> items = BuildTreeItems();
UIExpansionModel expansionModel = {};
expansionModel.Expand("scene");
const UIEditorTreeViewLayout layout =
BuildUIEditorTreeViewLayout(UIRect(20.0f, 30.0f, 280.0f, 240.0f), items, expansionModel);
const auto disclosureHit = HitTestUIEditorTreeView(layout, UIPoint(34.0f, 44.0f));
EXPECT_EQ(disclosureHit.kind, UIEditorTreeViewHitTargetKind::Disclosure);
EXPECT_EQ(disclosureHit.itemIndex, 0u);
const auto rowHit = HitTestUIEditorTreeView(layout, UIPoint(88.0f, 44.0f));
EXPECT_EQ(rowHit.kind, UIEditorTreeViewHitTargetKind::Row);
EXPECT_EQ(rowHit.itemIndex, 0u);
const auto emptyHit = HitTestUIEditorTreeView(layout, UIPoint(12.0f, 18.0f));
EXPECT_EQ(emptyHit.kind, UIEditorTreeViewHitTargetKind::None);
}
TEST(UIEditorTreeViewTest, BackgroundAndForegroundEmitStableCommands) {
const std::vector<UIEditorTreeViewItem> items = BuildTreeItems();
UIExpansionModel expansionModel = {};
expansionModel.Expand("scene");
expansionModel.Expand("ui");
UISelectionModel selectionModel = {};
selectionModel.SetSelection("camera");
UIEditorTreeViewState state = {};
state.hoveredItemId = "lights";
state.focused = true;
const UIEditorTreeViewLayout layout =
BuildUIEditorTreeViewLayout(UIRect(20.0f, 30.0f, 280.0f, 240.0f), items, expansionModel);
UIDrawList background("TreeViewBackground");
AppendUIEditorTreeViewBackground(background, layout, items, selectionModel, state);
ASSERT_EQ(background.GetCommandCount(), 4u);
EXPECT_EQ(background.GetCommands()[0].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(background.GetCommands()[1].type, UIDrawCommandType::RectOutline);
EXPECT_EQ(background.GetCommands()[2].type, UIDrawCommandType::FilledRect);
EXPECT_EQ(background.GetCommands()[3].type, UIDrawCommandType::FilledRect);
UIDrawList foreground("TreeViewForeground");
AppendUIEditorTreeViewForeground(foreground, layout, items);
ASSERT_EQ(foreground.GetCommandCount(), 20u);
EXPECT_EQ(foreground.GetCommands()[0].type, UIDrawCommandType::PushClipRect);
EXPECT_TRUE(ContainsTextCommand(foreground, "v"));
EXPECT_TRUE(ContainsTextCommand(foreground, "Scene"));
EXPECT_TRUE(ContainsTextCommand(foreground, "Camera"));
EXPECT_EQ(foreground.GetCommands()[19].type, UIDrawCommandType::PopClipRect);
}
} // namespace

View File

@@ -0,0 +1,235 @@
#include <gtest/gtest.h>
#include <XCEditor/Core/UIEditorTreeViewInteraction.h>
namespace {
using XCEngine::UI::UIInputEvent;
using XCEngine::UI::UIInputEventType;
using XCEngine::UI::UIPoint;
using XCEngine::UI::UIPointerButton;
using XCEngine::UI::UIRect;
using XCEngine::UI::Widgets::UIExpansionModel;
using XCEngine::UI::Widgets::UISelectionModel;
using XCEngine::UI::Editor::UIEditorTreeViewInteractionState;
using XCEngine::UI::Editor::UpdateUIEditorTreeViewInteraction;
using XCEngine::UI::Editor::Widgets::BuildUIEditorTreeViewLayout;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewHitTargetKind;
using XCEngine::UI::Editor::Widgets::UIEditorTreeViewItem;
std::vector<UIEditorTreeViewItem> BuildTreeItems() {
return {
{ "scene", "Scene", 0u, false, 0.0f },
{ "camera", "Camera", 1u, true, 0.0f },
{ "lights", "Lights", 1u, false, 0.0f },
{ "sun", "Directional Light", 2u, true, 0.0f },
{ "ui", "UI Root", 0u, false, 0.0f },
{ "canvas", "Canvas", 1u, true, 0.0f }
};
}
UIInputEvent MakePointerMove(float x, float y) {
UIInputEvent event = {};
event.type = UIInputEventType::PointerMove;
event.position = UIPoint(x, y);
return event;
}
UIInputEvent MakePointerDown(float x, float y, UIPointerButton button = UIPointerButton::Left) {
UIInputEvent event = {};
event.type = UIInputEventType::PointerButtonDown;
event.position = UIPoint(x, y);
event.pointerButton = button;
return event;
}
UIInputEvent MakePointerUp(float x, float y, UIPointerButton button = UIPointerButton::Left) {
UIInputEvent event = {};
event.type = UIInputEventType::PointerButtonUp;
event.position = UIPoint(x, y);
event.pointerButton = button;
return event;
}
UIInputEvent MakePointerLeave() {
UIInputEvent event = {};
event.type = UIInputEventType::PointerLeave;
return event;
}
UIInputEvent MakeFocusLost() {
UIInputEvent event = {};
event.type = UIInputEventType::FocusLost;
return event;
}
UIPoint RectCenter(const XCEngine::UI::UIRect& rect) {
return UIPoint(rect.x + rect.width * 0.5f, rect.y + rect.height * 0.5f);
}
} // namespace
TEST(UIEditorTreeViewInteractionTest, PointerMoveUpdatesHoveredItemAndHitTarget) {
const auto items = BuildTreeItems();
UISelectionModel selectionModel = {};
UIExpansionModel expansionModel = {};
expansionModel.Expand("scene");
UIEditorTreeViewInteractionState state = {};
const auto initialFrame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{});
const UIPoint lightsCenter = RectCenter(initialFrame.layout.rowRects[2]);
const auto frame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{ MakePointerMove(lightsCenter.x, lightsCenter.y) });
EXPECT_EQ(frame.result.hitTarget.kind, UIEditorTreeViewHitTargetKind::Row);
EXPECT_EQ(frame.result.hitTarget.itemIndex, 2u);
EXPECT_EQ(state.treeViewState.hoveredItemId, "lights");
}
TEST(UIEditorTreeViewInteractionTest, LeftClickRowSelectsItemAndFocusesTree) {
const auto items = BuildTreeItems();
UISelectionModel selectionModel = {};
UIExpansionModel expansionModel = {};
expansionModel.Expand("scene");
UIEditorTreeViewInteractionState state = {};
const auto initialFrame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{});
const UIPoint cameraCenter = RectCenter(initialFrame.layout.rowRects[1]);
const auto frame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{
MakePointerDown(cameraCenter.x, cameraCenter.y),
MakePointerUp(cameraCenter.x, cameraCenter.y)
});
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.selectionChanged);
EXPECT_EQ(frame.result.selectedItemId, "camera");
EXPECT_TRUE(selectionModel.IsSelected("camera"));
EXPECT_TRUE(state.treeViewState.focused);
}
TEST(UIEditorTreeViewInteractionTest, LeftClickDisclosureTogglesExpansionAndRebuildsLayout) {
const auto items = BuildTreeItems();
UISelectionModel selectionModel = {};
UIExpansionModel expansionModel = {};
expansionModel.Expand("scene");
UIEditorTreeViewInteractionState state = {};
const auto initialFrame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{});
const UIPoint lightsDisclosureCenter = RectCenter(initialFrame.layout.disclosureRects[2]);
const auto frame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{
MakePointerDown(lightsDisclosureCenter.x, lightsDisclosureCenter.y),
MakePointerUp(lightsDisclosureCenter.x, lightsDisclosureCenter.y)
});
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.expansionChanged);
EXPECT_EQ(frame.result.toggledItemId, "lights");
EXPECT_TRUE(expansionModel.IsExpanded("lights"));
ASSERT_EQ(frame.layout.visibleItemIndices.size(), 5u);
EXPECT_EQ(frame.layout.visibleItemIndices[3], 3u);
EXPECT_EQ(frame.layout.visibleItemIndices[4], 4u);
}
TEST(UIEditorTreeViewInteractionTest, RightClickRowSelectsItemAndMarksSecondaryClick) {
const auto items = BuildTreeItems();
UISelectionModel selectionModel = {};
UIExpansionModel expansionModel = {};
expansionModel.Expand("scene");
UIEditorTreeViewInteractionState state = {};
const auto initialFrame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{});
const UIPoint lightsCenter = RectCenter(initialFrame.layout.rowRects[2]);
const auto frame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{
MakePointerDown(lightsCenter.x, lightsCenter.y, UIPointerButton::Right),
MakePointerUp(lightsCenter.x, lightsCenter.y, UIPointerButton::Right)
});
EXPECT_TRUE(frame.result.consumed);
EXPECT_TRUE(frame.result.secondaryClicked);
EXPECT_TRUE(frame.result.selectionChanged);
EXPECT_EQ(frame.result.selectedItemId, "lights");
EXPECT_TRUE(selectionModel.IsSelected("lights"));
EXPECT_TRUE(state.treeViewState.focused);
}
TEST(UIEditorTreeViewInteractionTest, OutsideClickAndFocusLostClearFocusAndHover) {
const auto items = BuildTreeItems();
UISelectionModel selectionModel = {};
UIExpansionModel expansionModel = {};
expansionModel.Expand("scene");
UIEditorTreeViewInteractionState state = {};
state.treeViewState.focused = true;
state.treeViewState.hoveredItemId = "camera";
auto frame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{ MakePointerDown(400.0f, 260.0f), MakePointerUp(400.0f, 260.0f) });
EXPECT_FALSE(state.treeViewState.focused);
EXPECT_EQ(frame.result.hitTarget.kind, UIEditorTreeViewHitTargetKind::None);
frame = UpdateUIEditorTreeViewInteraction(
state,
selectionModel,
expansionModel,
UIRect(0.0f, 0.0f, 320.0f, 240.0f),
items,
{ MakePointerLeave(), MakeFocusLost() });
EXPECT_FALSE(state.treeViewState.focused);
EXPECT_TRUE(state.treeViewState.hoveredItemId.empty());
EXPECT_FALSE(state.hasPointerPosition);
}