From 646e5855ce53eff816cd35271385f3dea8f10633 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Sun, 5 Apr 2026 07:03:51 +0800 Subject: [PATCH] Extract XCUI selection model and layout lab click selection --- docs/plan/XCUI_Phase_Status_2026-04-05.md | 10 ++-- engine/CMakeLists.txt | 2 + .../XCEngine/UI/Widgets/UISelectionModel.h | 26 ++++++++++ engine/src/UI/Widgets/UISelectionModel.cpp | 49 +++++++++++++++++++ .../src/XCUIBackend/XCUILayoutLabRuntime.cpp | 42 ++++++++++++---- .../src/XCUIBackend/XCUILayoutLabRuntime.h | 2 + new_editor/src/panels/XCUILayoutLabPanel.cpp | 9 ++-- tests/Core/UI/CMakeLists.txt | 1 + tests/Core/UI/test_ui_selection_model.cpp | 42 ++++++++++++++++ .../test_xcui_layout_lab_runtime.cpp | 34 +++++++++++++ 10 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 engine/include/XCEngine/UI/Widgets/UISelectionModel.h create mode 100644 engine/src/UI/Widgets/UISelectionModel.cpp create mode 100644 tests/Core/UI/test_ui_selection_model.cpp diff --git a/docs/plan/XCUI_Phase_Status_2026-04-05.md b/docs/plan/XCUI_Phase_Status_2026-04-05.md index 991c9f16..df21625e 100644 --- a/docs/plan/XCUI_Phase_Status_2026-04-05.md +++ b/docs/plan/XCUI_Phase_Status_2026-04-05.md @@ -32,6 +32,7 @@ Old `editor` replacement is explicitly out of scope for this phase. - Shared text-editing primitives now live under `engine/include/XCEngine/UI/Text` and `engine/src/UI/Text`, so UTF-8 caret movement, line splitting, and multiline navigation are no longer trapped inside `XCUI Demo`. - Shared text-input controller/state now also lives under `engine/include/XCEngine/UI/Text` and `engine/src/UI/Text`, so character insertion, backspace/delete, submit, and multiline key handling no longer need to be reimplemented per host. - Shared editor collection primitive classification and metric helpers now also live under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets`, covering the current `ScrollView` / `TreeView` / `ListView` / `PropertySection` / `FieldRow` prototype taxonomy. +- Shared single-selection state now also lives under `engine/include/XCEngine/UI/Widgets` and `engine/src/UI/Widgets` as `UISelectionModel`, so collection-style widget selection no longer has to stay private to `LayoutLab`. - Core regression coverage now includes `UIContext`, layout, style, runtime screen player/system, and real document-host tests through `core_ui_tests`. Current gap: @@ -66,6 +67,7 @@ Current gap: - `LayoutLab` now includes a `ScrollView` prototype and a more editor-like three-column authored layout. - `LayoutLab` now also covers editor-facing widget prototypes: `TreeView`, `TreeItem`, `ListView`, `ListItem`, `PropertySection`, and `FieldRow`. - `LayoutLab` now consumes the shared `UIEditorCollectionPrimitives` helper layer for collection-widget tag classification, clipping flags, and default metric resolution instead of keeping that taxonomy private to the sandbox runtime. +- `LayoutLab` now also consumes the shared `UISelectionModel` for click-selection persistence across collection-style widgets, and the diagnostics panel now exposes both hovered and selected element ids. - Panel diagnostics were expanded to clearly separate preview/runtime/input state and native vs legacy paths. - The editor bridge layer now has smoke coverage for swapchain after-UI rendering hooks and SRV-backed ImGui texture descriptor registration. - `Application` no longer owns the ImGui backend directly; window presentation now routes through `IWindowUICompositor` with an `ImGuiWindowUICompositor` implementation, which currently delegates to `IEditorHostCompositor` / `ImGuiHostCompositor`. @@ -78,17 +80,17 @@ Current gap: - The shell is still ImGui-hosted. - Legacy hosted preview still depends on an active ImGui window context for inline presentation. -- Editor-specialized widgets are still incomplete at the shared-module level: the authored prototypes exist, but virtualization, selection models, command routing, property editing models, toolbar/menu chrome, and icon-atlas widgets are not yet extracted into reusable XCUI modules. +- Editor-specialized widgets are still incomplete at the shared-module level: the authored prototypes exist, but virtualization, multi-selection/tree expansion state, command routing, property editing models, toolbar/menu chrome, and icon-atlas widgets are not yet extracted into reusable XCUI modules. ## Validated This Phase - `new_editor_xcui_demo_runtime_tests`: `7/7` -- `new_editor_xcui_layout_lab_runtime_tests`: `6/6` +- `new_editor_xcui_layout_lab_runtime_tests`: `7/7` - `new_editor_xcui_rhi_command_compiler_tests`: `6/6` - `new_editor_xcui_rhi_render_backend_tests`: `5/5` - `new_editor_xcui_hosted_preview_presenter_tests`: `12/12` - `XCNewEditor` Debug target builds successfully -- `core_ui_tests`: `28/28` +- `core_ui_tests`: `30/30` - `scene_tests`: `65/65` - `core_ui_style_tests`: `5/5` - `ui_resource_tests`: `11/11` @@ -101,6 +103,7 @@ Current gap: - Common-core `UITextEditing` extraction now owns UTF-8 offset stepping, codepoint counting, line splitting, and vertical caret motion with dedicated `core_ui_tests` coverage. - Common-core `UITextInputController` extraction now owns per-field text state, character insertion, enter-submit, and multiline keyboard editing behavior with dedicated `core_ui_tests` coverage. - Common-core `UIEditorCollectionPrimitives` extraction now owns the editor collection tag taxonomy and default metric resolution used by current `LayoutLab` widget prototypes, with dedicated `core_ui_tests` coverage. +- Common-core `UISelectionModel` extraction now owns reusable single-selection state for collection-style widgets, with dedicated `core_ui_tests` coverage. - Demo runtime text editing was extended with: - click-to-place caret - `Delete` support @@ -109,6 +112,7 @@ Current gap: - Demo authored resources updated to exercise the input field. - LayoutLab `ScrollView` prototype with clipping and hover rejection outside clipped content. - LayoutLab editor-widget prototypes for tree/list/property-style sections with dedicated runtime coverage. +- LayoutLab click-selection now persists through the shared `UISelectionModel`, including selected-state diagnostics and reusable visual selection feedback on cards, collection rows, and field rows. - Schema document support extended with: - retained `UISchemaDefinition` data on `UIDocumentModel` - artifact schema version bump for UI documents diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 0315755d..ab68e7b7 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -521,7 +521,9 @@ add_library(XCEngine STATIC ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextEditing.cpp ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Text/UITextInputController.cpp ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UIEditorCollectionPrimitives.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Widgets/UISelectionModel.h ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UIEditorCollectionPrimitives.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/UI/Widgets/UISelectionModel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenTypes.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenDocumentHost.h ${CMAKE_CURRENT_SOURCE_DIR}/include/XCEngine/UI/Runtime/UIScreenPlayer.h diff --git a/engine/include/XCEngine/UI/Widgets/UISelectionModel.h b/engine/include/XCEngine/UI/Widgets/UISelectionModel.h new file mode 100644 index 00000000..7eb060e5 --- /dev/null +++ b/engine/include/XCEngine/UI/Widgets/UISelectionModel.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +class UISelectionModel { +public: + bool HasSelection() const; + const std::string& GetSelectedId() const; + bool IsSelected(std::string_view id) const; + + bool SetSelection(std::string selectionId); + bool ClearSelection(); + bool ToggleSelection(std::string selectionId); + +private: + std::string m_selectedId = {}; +}; + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/engine/src/UI/Widgets/UISelectionModel.cpp b/engine/src/UI/Widgets/UISelectionModel.cpp new file mode 100644 index 00000000..5137ee59 --- /dev/null +++ b/engine/src/UI/Widgets/UISelectionModel.cpp @@ -0,0 +1,49 @@ +#include + +namespace XCEngine { +namespace UI { +namespace Widgets { + +bool UISelectionModel::HasSelection() const { + return !m_selectedId.empty(); +} + +const std::string& UISelectionModel::GetSelectedId() const { + return m_selectedId; +} + +bool UISelectionModel::IsSelected(std::string_view id) const { + return !m_selectedId.empty() && m_selectedId == id; +} + +bool UISelectionModel::SetSelection(std::string selectionId) { + if (m_selectedId == selectionId) { + return false; + } + + m_selectedId = std::move(selectionId); + return true; +} + +bool UISelectionModel::ClearSelection() { + if (m_selectedId.empty()) { + return false; + } + + m_selectedId.clear(); + return true; +} + +bool UISelectionModel::ToggleSelection(std::string selectionId) { + if (m_selectedId == selectionId) { + m_selectedId.clear(); + return true; + } + + m_selectedId = std::move(selectionId); + return true; +} + +} // namespace Widgets +} // namespace UI +} // namespace XCEngine diff --git a/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.cpp b/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.cpp index 11218b8f..03e75331 100644 --- a/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.cpp +++ b/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -77,6 +78,7 @@ struct RuntimeBuildContext { std::vector nodes = {}; std::unordered_map nodeIndexById = {}; std::unordered_map rectsById = {}; + UIWidgets::UISelectionModel selectionModel = {}; bool documentsReady = false; std::string statusMessage = {}; XCUIAssetDocumentSource documentSource = XCUIAssetDocumentSource( @@ -752,6 +754,7 @@ void DrawNode( bodyFont); } } else if (node.tagName == "Card") { + const bool selected = state.selectionModel.IsSelected(node.id); const Color cardColor = ResolveColorToken( state.theme, node.tone == "accent" @@ -799,17 +802,25 @@ void DrawNode( UIColor(1.0f, 0.82f, 0.45f, 1.0f), 2.0f, rounding); + } else if (selected) { + drawList.AddRectOutline( + node.rect, + ToUIColor(ResolveColorToken(state.theme, "color.accent", Color(0.30f, 0.46f, 0.58f, 1.0f))), + 2.0f, + rounding); } } else if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::TreeItem || primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::ListItem) { const bool hovered = node.id == hoveredId; - const Color rowColor = hovered - ? ResolveColorToken(state.theme, "color.card.alt", Color(0.20f, 0.27f, 0.34f, 1.0f)) - : ResolveColorToken(state.theme, "color.card", Color(0.12f, 0.17f, 0.23f, 1.0f)); - const Color borderColor = ResolveColorToken( - state.theme, - "color.border", - Color(0.24f, 0.34f, 0.43f, 1.0f)); + const bool selected = state.selectionModel.IsSelected(node.id); + const Color rowColor = selected + ? ResolveColorToken(state.theme, "color.accent", Color(0.30f, 0.46f, 0.58f, 1.0f)) + : (hovered + ? ResolveColorToken(state.theme, "color.card.alt", Color(0.20f, 0.27f, 0.34f, 1.0f)) + : ResolveColorToken(state.theme, "color.card", Color(0.12f, 0.17f, 0.23f, 1.0f))); + const Color borderColor = selected + ? ResolveColorToken(state.theme, "color.accent", Color(0.30f, 0.46f, 0.58f, 1.0f)) + : ResolveColorToken(state.theme, "color.border", Color(0.24f, 0.34f, 0.43f, 1.0f)); const Color textColor = ResolveColorToken( state.theme, "color.text", @@ -848,6 +859,7 @@ void DrawNode( } } else if (primitiveKind == UIWidgets::UIEditorCollectionPrimitiveKind::FieldRow) { const bool hovered = node.id == hoveredId; + const bool selected = state.selectionModel.IsSelected(node.id); const Color textColor = ResolveColorToken( state.theme, "color.text", @@ -856,7 +868,7 @@ void DrawNode( state.theme, "color.text.muted", Color(0.72f, 0.79f, 0.86f, 1.0f)); - const Color lineColor = hovered + const Color lineColor = selected || hovered ? ResolveColorToken(state.theme, "color.accent", Color(0.30f, 0.46f, 0.58f, 1.0f)) : ResolveColorToken(state.theme, "color.border", Color(0.24f, 0.34f, 0.43f, 1.0f)); const float inset = ResolveFloatToken(state.theme, "space.cardInset", 12.0f); @@ -955,6 +967,7 @@ bool XCUILayoutLabRuntime::ReloadDocuments() { state.nodes.clear(); state.nodeIndexById.clear(); state.rectsById.clear(); + state.selectionModel.ClearSelection(); state.documentSource.SetPathSet(XCUIAssetDocumentSource::MakeLayoutLabPathSet()); if (!state.documentSource.Reload()) { @@ -1027,13 +1040,24 @@ const XCUILayoutLabFrameResult& XCUILayoutLabRuntime::Update(const XCUILayoutLab state.rectsById[state.nodes[0].id] = state.nodes[0].rect; } + std::size_t hoveredIndex = kInvalidIndex; if (input.pointerInside) { - const std::size_t hoveredIndex = HitTest(state, input.pointerPosition); + hoveredIndex = HitTest(state, input.pointerPosition); if (hoveredIndex != kInvalidIndex) { state.frameResult.stats.hoveredElementId = state.nodes[hoveredIndex].id; } } + if (input.pointerPressed) { + if (hoveredIndex != kInvalidIndex) { + state.selectionModel.SetSelection(state.nodes[hoveredIndex].id); + } else { + state.selectionModel.ClearSelection(); + } + } + + state.frameResult.stats.selectedElementId = state.selectionModel.GetSelectedId(); + UIDrawList drawList("XCUI Layout Lab"); drawList.PushClipRect(input.canvasRect); DrawNode(state, 0u, drawList, state.frameResult.stats.hoveredElementId); diff --git a/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.h b/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.h index e23d4cd6..94ba97fb 100644 --- a/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.h +++ b/new_editor/src/XCUIBackend/XCUILayoutLabRuntime.h @@ -17,6 +17,7 @@ struct XCUILayoutLabInputState { UI::UIRect canvasRect = {}; UI::UIPoint pointerPosition = {}; bool pointerInside = false; + bool pointerPressed = false; }; struct XCUILayoutLabFrameStats { @@ -47,6 +48,7 @@ struct XCUILayoutLabFrameStats { std::size_t propertySectionCount = 0; std::size_t fieldRowCount = 0; std::string hoveredElementId = {}; + std::string selectedElementId = {}; }; struct XCUILayoutLabFrameResult { diff --git a/new_editor/src/panels/XCUILayoutLabPanel.cpp b/new_editor/src/panels/XCUILayoutLabPanel.cpp index 67458533..3fb4b5f5 100644 --- a/new_editor/src/panels/XCUILayoutLabPanel.cpp +++ b/new_editor/src/panels/XCUILayoutLabPanel.cpp @@ -241,6 +241,7 @@ void XCUILayoutLabPanel::Render() { input.pointerPosition = UI::UIPoint(ImGui::GetIO().MousePos.x, ImGui::GetIO().MousePos.y); input.pointerInside = validCanvas && ImGui::IsItemHovered(); } + input.pointerPressed = input.pointerInside && ImGui::IsMouseClicked(ImGuiMouseButton_Left); const ::XCEngine::Editor::XCUIBackend::XCUILayoutLabFrameResult& frame = m_runtime.Update(input); @@ -337,17 +338,19 @@ void XCUILayoutLabPanel::Render() { "Native note: %s", stats.nativeOverlayStatusMessage.empty() ? "none" : stats.nativeOverlayStatusMessage.c_str()); ImGui::Text( - "Hovered: %s | canvas: %.0f x %.0f", + "Hovered: %s | Selected: %s | canvas: %.0f x %.0f", stats.hoveredElementId.empty() ? "none" : stats.hoveredElementId.c_str(), + stats.selectedElementId.empty() ? "none" : stats.selectedElementId.c_str(), input.canvasRect.width, input.canvasRect.height); ImGui::SeparatorText("Input"); ImGui::Text( - "Pointer: %.0f, %.0f | inside %s", + "Pointer: %.0f, %.0f | inside %s | pressed %s", input.pointerPosition.x, input.pointerPosition.y, - input.pointerInside ? "yes" : "no"); + input.pointerInside ? "yes" : "no", + input.pointerPressed ? "yes" : "no"); ImGui::EndChild(); ImGui::End(); diff --git a/tests/Core/UI/CMakeLists.txt b/tests/Core/UI/CMakeLists.txt index 463b5388..04a41d2e 100644 --- a/tests/Core/UI/CMakeLists.txt +++ b/tests/Core/UI/CMakeLists.txt @@ -6,6 +6,7 @@ set(UI_TEST_SOURCES test_ui_core.cpp test_ui_editor_collection_primitives.cpp test_layout_engine.cpp + test_ui_selection_model.cpp test_ui_runtime.cpp test_ui_text_editing.cpp test_ui_text_input_controller.cpp diff --git a/tests/Core/UI/test_ui_selection_model.cpp b/tests/Core/UI/test_ui_selection_model.cpp new file mode 100644 index 00000000..13d8754d --- /dev/null +++ b/tests/Core/UI/test_ui_selection_model.cpp @@ -0,0 +1,42 @@ +#include + +#include + +namespace { + +using XCEngine::UI::Widgets::UISelectionModel; + +TEST(UISelectionModelTest, SetAndClearSelectionTrackCurrentId) { + UISelectionModel selection = {}; + + EXPECT_FALSE(selection.HasSelection()); + EXPECT_TRUE(selection.GetSelectedId().empty()); + + EXPECT_TRUE(selection.SetSelection("assetLighting")); + EXPECT_TRUE(selection.HasSelection()); + EXPECT_TRUE(selection.IsSelected("assetLighting")); + EXPECT_EQ(selection.GetSelectedId(), "assetLighting"); + + EXPECT_FALSE(selection.SetSelection("assetLighting")); + EXPECT_TRUE(selection.ClearSelection()); + EXPECT_FALSE(selection.HasSelection()); + EXPECT_TRUE(selection.GetSelectedId().empty()); + EXPECT_FALSE(selection.ClearSelection()); +} + +TEST(UISelectionModelTest, ToggleSelectionSelectsAndDeselectsMatchingId) { + UISelectionModel selection = {}; + + EXPECT_TRUE(selection.ToggleSelection("treeScenes")); + EXPECT_EQ(selection.GetSelectedId(), "treeScenes"); + + EXPECT_TRUE(selection.ToggleSelection("treeScenes")); + EXPECT_TRUE(selection.GetSelectedId().empty()); + + EXPECT_TRUE(selection.ToggleSelection("treeMaterials")); + EXPECT_EQ(selection.GetSelectedId(), "treeMaterials"); + EXPECT_TRUE(selection.ToggleSelection("treeUi")); + EXPECT_EQ(selection.GetSelectedId(), "treeUi"); +} + +} // namespace diff --git a/tests/NewEditor/test_xcui_layout_lab_runtime.cpp b/tests/NewEditor/test_xcui_layout_lab_runtime.cpp index 027e846d..1aa3efdf 100644 --- a/tests/NewEditor/test_xcui_layout_lab_runtime.cpp +++ b/tests/NewEditor/test_xcui_layout_lab_runtime.cpp @@ -216,3 +216,37 @@ TEST(NewEditorXCUILayoutLabRuntimeTest, HoverIgnoresClippedScrollViewContent) { ASSERT_TRUE(frame.stats.documentsReady); EXPECT_TRUE(frame.stats.hoveredElementId.empty()); } + +TEST(NewEditorXCUILayoutLabRuntimeTest, ClickSelectionPersistsOnSharedCollectionItems) { + XCEngine::Editor::XCUIBackend::XCUILayoutLabRuntime runtime; + ASSERT_TRUE(runtime.ReloadDocuments()); + + const auto& baseline = runtime.Update(BuildInputState()); + ASSERT_TRUE(baseline.stats.documentsReady); + + XCEngine::UI::UIRect targetRect = {}; + ASSERT_TRUE(runtime.TryGetElementRect("fieldPosition", targetRect)); + + XCEngine::Editor::XCUIBackend::XCUILayoutLabInputState clickInput = BuildInputState(); + clickInput.pointerPosition = XCEngine::UI::UIPoint( + targetRect.x + targetRect.width * 0.5f, + targetRect.y + targetRect.height * 0.5f); + clickInput.pointerPressed = true; + const auto& selectedFrame = runtime.Update(clickInput); + + ASSERT_TRUE(selectedFrame.stats.documentsReady); + ASSERT_FALSE(selectedFrame.stats.hoveredElementId.empty()); + EXPECT_EQ( + selectedFrame.stats.selectedElementId, + selectedFrame.stats.hoveredElementId); + + const std::string selectedElementId = selectedFrame.stats.selectedElementId; + XCEngine::UI::UIRect selectedRect = {}; + EXPECT_TRUE(runtime.TryGetElementRect(selectedElementId, selectedRect)); + EXPECT_GT(selectedRect.width, 0.0f); + EXPECT_GT(selectedRect.height, 0.0f); + + const auto& persistedFrame = runtime.Update(BuildInputState()); + ASSERT_TRUE(persistedFrame.stats.documentsReady); + EXPECT_EQ(persistedFrame.stats.selectedElementId, selectedElementId); +}