diff --git a/docs/used/NewEditor_FilterableTreeHostUnificationPlan_2026-04-22.md b/docs/used/NewEditor_FilterableTreeHostUnificationPlan_2026-04-22.md new file mode 100644 index 00000000..5c887157 --- /dev/null +++ b/docs/used/NewEditor_FilterableTreeHostUnificationPlan_2026-04-22.md @@ -0,0 +1,279 @@ +# NewEditor Filterable Tree Host Unification Plan + +Date: 2026-04-22 +Status: Completed + +## Scope + +This pass only covers shared tree filtering for: + +1. `new_editor/app/Features/Hierarchy/HierarchyPanel` +2. `new_editor/app/Features/Project/ProjectPanel` left folder tree + +It does not widen into: + +1. Project right asset browser filtering +2. global editor search +3. `UIEditorTreeView` visual redesign +4. docking / workspace / detached window behavior changes + +## Confirmed Problem + +The current architecture has three clear layers: + +1. canonical tree data +2. tree host logic +3. pure tree widget + +But only layer 3 is currently reusable. + +Current facts: + +1. `HierarchyModel` owns canonical scene tree data and emits flat `UIEditorTreeViewItem` rows from nested `HierarchyNode`s. +2. `ProjectBrowserModel::RefreshFolderTree()` owns canonical folder tree data and emits flat `UIEditorTreeViewItem` rows for the left folder tree. +3. `UIEditorTreeView` and `UIEditorTreeViewInteraction` are still pure tree control code: + - tree layout + - scroll + - hit testing + - selection / expansion / keyboard / rename support + - no search query state + - no text field state + - no filtering semantics + +So if filtering is added directly inside `HierarchyPanel` and again inside `ProjectPanel`, the editor will duplicate: + +1. search field state +2. search field layout / drawing +3. search input interaction and focus arbitration +4. filter query normalization +5. ancestor-retention filtering logic +6. filtered-tree expansion override logic +7. query-change scroll reset / clamp behavior + +That would create the exact kind of host-level duplication we have been trying to eliminate. + +## Architectural Decision + +Do not stuff search UI directly into `UIEditorTreeView`. + +`UIEditorTreeView` must stay a pure tree widget. + +Instead, introduce a reusable host layer above it: + +1. shared filter host UI and interaction +2. shared tree filtering algorithm +3. panel-owned canonical source items + +The ownership split after this pass should be: + +1. `UIEditorTreeView` + - remains a pure rendering / interaction widget for already-supplied tree items +2. new shared filterable-tree host layer + - owns search box UI + - owns search query state + - owns shared query normalization and filtered-item derivation + - owns filtered-view expansion override and scroll reset behavior +3. `HierarchyPanel` / `ProjectPanel` + - continue to own canonical source trees and panel-specific commands + - consume the shared host instead of reimplementing search locally + +## Target End State + +After this pass: + +1. both `HierarchyPanel` and `ProjectPanel` left tree expose the same first-pass filter behavior +2. `UIEditorTreeView` remains free of search-field UI and search-specific business state +3. canonical tree data in `HierarchyModel` and `ProjectBrowserModel` remains unfiltered and authoritative +4. the filtered view is derived at host level from canonical items, not written back into model ownership +5. existing selection / rename / drag / context behavior continues to operate on stable original item ids + +## Shared First-Pass Filter Semantics + +The first implementation for both trees must be identical: + +1. case-insensitive substring match against `UIEditorTreeViewItem.label` +2. if a descendant matches, retain the full ancestor path +3. hide branches that contain neither a direct match nor a matching descendant +4. while filtering is active, matched ancestor paths are forced visible without mutating the user’s persistent expansion state +5. clearing the query restores the normal tree view and the panel’s real expansion state + +This pass intentionally does not add: + +1. path-token matching +2. fuzzy ranking +3. tag / type filtering +4. result counters + +## Root Cause + +The root problem is not “tree filtering does not exist”. + +The root problem is that the editor currently has: + +1. a reusable pure tree widget +2. no reusable host above it for tree-specific search + +That missing middle layer is why the same feature would otherwise be copied into both `HierarchyPanel` and `ProjectPanel`. + +This pass closes that missing host layer instead of adding two panel-local patches. + +## Execution Plan + +### Phase A. Freeze the Behavioral Contract + +Before editing code, lock these rules: + +1. both trees share the same filter behavior in v1 +2. canonical tree models stay unchanged as source of truth +3. `UIEditorTreeView` does not gain embedded search-bar UI +4. filter activation must not rewrite persistent expansion state + +### Phase B. Introduce Shared Filtered-Tree Derivation + +Add a reusable filtered-tree helper that works from canonical flat pre-order `UIEditorTreeViewItem` input. + +This helper must: + +1. accept canonical source items +2. accept a normalized query +3. retain matching rows and their ancestor chain +4. preserve original `itemId` +5. preserve correct visible depth +6. return enough metadata to drive temporary expansion override while filtering + +Important constraint: + +`ProjectBrowserModel` and `HierarchyModel` must not be rewritten to permanently store filtered state. + +They keep producing canonical source items. + +### Phase C. Introduce a Shared Filterable Tree Host Layer + +Add a reusable host abstraction above `UIEditorTreeView`, conceptually: + +1. search text-field state +2. search text-field layout / draw +3. text input interaction +4. filtered-item cache / frame result +5. temporary expansion override when query is active +6. tree viewport layout split: + - top search box + - tree body below it +7. query-change scroll reset and offset clamp + +This host is the shared reusable layer both panels consume. + +It may live alongside `UIEditorTreeView` in the shared collections layer, but it must remain composition over `UIEditorTreeView`, not mutation of `UIEditorTreeView` itself. + +### Phase D. Integrate `HierarchyPanel` + +Refit `HierarchyPanel` to: + +1. keep `HierarchyModel` as canonical source +2. continue building canonical tree items from `HierarchyModel` +3. feed those canonical items into the shared filter host +4. pass the host’s filtered tree output into existing tree interaction / draw paths +5. keep rename / selection / drag addressing canonical ids + +While filtering is active: + +1. matching nodes and retained ancestors remain interactive +2. selection and rename continue to target original ids +3. drag / reparent logic still resolves against original ids visible in the filtered view + +### Phase E. Integrate `ProjectPanel` Left Tree + +Refit `ProjectPanel` left tree to: + +1. keep `ProjectBrowserModel::m_treeItems` as canonical source tree +2. keep `m_folderEntries` and folder navigation ownership unchanged +3. feed canonical left-tree items into the shared filter host +4. run tree interaction against the host output instead of duplicating project-only filtering logic + +The filter affects only the left folder tree. + +It must not change: + +1. current folder ownership +2. right asset browser model ownership +3. breadcrumb ownership + +### Phase F. Reconcile Selection, Expansion, and Query Lifecycle + +Make lifecycle rules explicit: + +1. when query changes, clamp or reset tree scroll to avoid stale offsets +2. when query becomes empty, restore normal expansion semantics +3. if the selected item is still visible in filtered results, keep selection +4. if the selected item is hidden by filter, do not rewrite canonical selection just because the view is filtered +5. rename session startup must only begin for currently visible rows + +### Phase G. Validate + +After implementation: + +1. build `XCUIEditorApp` +2. manual smoke-test `HierarchyPanel` +3. manual smoke-test `ProjectPanel` left tree +4. confirm no regression in detached windows / workspace / docking paths from this pass + +## Verification Matrix + +## Progress Update (2026-04-23) + +Completed in code: + +1. shared `UIEditorFilterableTreeHost` host layer is in place +2. `HierarchyPanel` now routes search UI, filtered-item derivation, rename bounds, drag/drop, selection, and draw through the shared host output +3. `ProjectPanel` left folder tree now routes host layout, tree interaction, rename bounds, drop hit-testing, drag preview, and draw through the same shared host output +4. canonical source ownership remains in `HierarchyModel` and `ProjectBrowserModel`; filtering stays transient at host layer + +Current validation state: + +1. `XCUIEditorApp` build was re-run on 2026-04-23 +2. the build reached compilation of `HierarchyPanel.cpp` and `ProjectPanel.cpp` after this pass +3. full app build is currently blocked by unrelated existing errors in the Win32 window shell path, currently surfacing in `new_editor/app/Platform/Win32/EditorWindowChromeController.cpp` and `new_editor/app/Platform/Win32/EditorWindow.cpp` +4. because the app does not currently finish building, manual UI smoke validation for this plan is still pending + +### Hierarchy + +1. search box appears at top of hierarchy panel +2. typing filters rows by object name +3. matched descendants keep ancestor chain visible +4. clearing the query restores full tree +5. selection still works +6. rename still works +7. drag / reparent still works on visible nodes +8. tree scroll still works before, during, and after filtering + +### Project Left Tree + +1. search box appears at top of left folder tree +2. typing filters folders by label +3. matched descendants keep ancestor chain visible +4. clearing the query restores full tree +5. folder navigation still works +6. right asset browser updates normally when a visible folder is selected +7. left-tree drag / reparent still works on visible nodes +8. tree scroll still works before, during, and after filtering + +## Red Lines + +Do not: + +1. embed search-bar UI directly into `UIEditorTreeView` +2. mutate canonical `HierarchyModel` / `ProjectBrowserModel` into filtered source-of-truth state +3. create separate duplicated search implementations in `HierarchyPanel` and `ProjectPanel` +4. rewrite unrelated project browser right-pane logic in this pass +5. break current tree rename / drag / selection behavior just to simplify the filter implementation + +## Completion Criteria + +This pass is complete only when: + +1. both trees use one shared host-level filter architecture +2. `UIEditorTreeView` stays a pure tree widget +3. both panels share the same first-pass filter semantics +4. canonical tree models remain unpolluted by transient filter state +5. the editor builds +6. hierarchy and project-left-tree smoke validation both pass diff --git a/new_editor/app/Features/Hierarchy/HierarchyPanel.cpp b/new_editor/app/Features/Hierarchy/HierarchyPanel.cpp index c92e58cc..f3e6dfb5 100644 --- a/new_editor/app/Features/Hierarchy/HierarchyPanel.cpp +++ b/new_editor/app/Features/Hierarchy/HierarchyPanel.cpp @@ -1,5 +1,6 @@ #include "HierarchyPanel.h" #include "Rendering/Assets/BuiltInIcons.h" +#include #include #include "Scene/EditorSceneRuntime.h" #include "State/EditorCommandFocusService.h" @@ -85,6 +86,8 @@ void HierarchyPanel::SetBuiltInIcons(const BuiltInIcons* icons) { } void HierarchyPanel::ResetInteractionState() { + m_filterHostState = {}; + m_filterHostFrame = {}; m_treeInteractionState = {}; m_treeFrame = {}; m_dragState = {}; @@ -301,11 +304,20 @@ UIRect HierarchyPanel::BuildRenameBounds( const Widgets::UIEditorTreeViewLayout& layout) const { return BuildUIEditorTreeViewInlineRenameBounds( layout, - m_treeItems, + GetPresentedTreeItems(), itemId, ResolveUIEditorTreeViewHostedTextFieldMetrics()); } +const std::vector& HierarchyPanel::GetPresentedTreeItems() const { + return ResolveUIEditorFilterableTreeHostItems(m_filterHostFrame); +} + +const ::XCEngine::UI::Widgets::UIExpansionModel& +HierarchyPanel::GetPresentedExpansionModel() const { + return ResolveUIEditorFilterableTreeHostExpansionModel(m_filterHostFrame); +} + bool HierarchyPanel::HasActivePointerCapture() const { return TreeDrag::HasActivePointerCapture(m_dragState) || HasActiveUIEditorTreeViewPointerCapture(m_treeInteractionState); @@ -417,12 +429,16 @@ void HierarchyPanel::ProcessDragAndFrameEvents( const std::vector& inputEvents, const UIRect& bounds, const UIEditorHostedPanelDispatchEntry& dispatchEntry) { + const std::vector& presentedItems = + GetPresentedTreeItems(); const std::vector filteredEvents = BuildUIEditorHostedTreeViewInputEvents( bounds, inputEvents, UIEditorHostedTreeViewInputOptions{ .allowInteraction = dispatchEntry.allowInteraction, - .hasInputFocus = dispatchEntry.focused, + .hasInputFocus = + dispatchEntry.focused && + !IsUIEditorFilterableTreeHostSearchFocused(m_filterHostState), .captureActive = HasActivePointerCapture() }, dispatchEntry.focusGained, @@ -473,7 +489,7 @@ void HierarchyPanel::ProcessDragAndFrameEvents( TreeDrag::ProcessInputEvents( m_dragState, m_treeFrame.layout, - m_treeItems, + presentedItems, filteredEvents, bounds, callbacks, @@ -484,10 +500,17 @@ void HierarchyPanel::ProcessDragAndFrameEvents( } if (dragResult.dropCommitted) { SyncModelFromScene(); + m_filterHostFrame = UpdateUIEditorFilterableTreeHost( + m_filterHostState, + m_filterHostFrame.layout.bounds, + "Hierarchy.TreeFilter", + {}, + m_treeItems, + m_expansion); m_treeFrame.layout = Widgets::BuildUIEditorTreeViewLayout( bounds, - m_treeItems, - m_expansion, + GetPresentedTreeItems(), + GetPresentedExpansionModel(), ResolveUIEditorTreeViewMetrics(), m_treeInteractionState.verticalOffset); EmitReparentEvent( @@ -519,31 +542,57 @@ void HierarchyPanel::Update( } m_visible = true; - const std::vector filteredEvents = BuildUIEditorHostedTreeViewInputEvents( + const std::vector panelFilteredEvents = BuildUIEditorPanelInputEvents( dispatchEntry.bounds, inputEvents, - UIEditorHostedTreeViewInputOptions{ - .allowInteraction = dispatchEntry.allowInteraction, - .hasInputFocus = dispatchEntry.focused, - .captureActive = HasActivePointerCapture() + UIEditorPanelInputFilterOptions{ + .allowPointerInBounds = dispatchEntry.allowInteraction, + .allowPointerWhileCaptured = HasActivePointerCapture(), + .allowKeyboardInput = dispatchEntry.focused, + .allowFocusEvents = + dispatchEntry.focused || + HasActivePointerCapture() || + dispatchEntry.focusGained || + dispatchEntry.focusLost, + .includePointerLeave = + dispatchEntry.allowInteraction || HasActivePointerCapture() }, dispatchEntry.focusGained, dispatchEntry.focusLost); - SyncTreeFocusState(filteredEvents); + m_filterHostFrame = UpdateUIEditorFilterableTreeHost( + m_filterHostState, + dispatchEntry.bounds, + "Hierarchy.TreeFilter", + panelFilteredEvents, + m_treeItems, + m_expansion); + if (m_filterHostFrame.result.queryChanged) { + m_treeInteractionState.verticalOffset = 0.0f; + } + if (IsUIEditorFilterableTreeHostSearchFocused(m_filterHostState)) { + m_treeInteractionState.treeViewState.focused = false; + } + SyncTreeFocusState(panelFilteredEvents); TryClaimHostedPanelCommandFocus( m_commandFocusService, EditorActionRoute::Hierarchy, - filteredEvents, + panelFilteredEvents, dispatchEntry.bounds, dispatchEntry.allowInteraction); + const std::vector& presentedItems = + GetPresentedTreeItems(); const Widgets::UIEditorTreeViewMetrics treeMetrics = ResolveUIEditorTreeViewMetrics(); + ::XCEngine::UI::Widgets::UIExpansionModel* activeExpansionModel = + m_filterHostFrame.result.filteringActive + ? &m_filterHostFrame.filteredExpansionModel + : &m_expansion; const Widgets::UIEditorTreeViewLayout layout = Widgets::BuildUIEditorTreeViewLayout( - dispatchEntry.bounds, - m_treeItems, - m_expansion, + m_filterHostFrame.layout.treeRect, + presentedItems, + *activeExpansionModel, treeMetrics, m_treeInteractionState.verticalOffset); @@ -551,23 +600,35 @@ void HierarchyPanel::Update( m_treeFrame.layout = layout; m_treeFrame.result = {}; TryStartQueuedRenameSession(layout); - UpdateRenameSession(filteredEvents, layout); + UpdateRenameSession(panelFilteredEvents, layout); return; } + const std::vector treeEvents = BuildUIEditorHostedTreeViewInputEvents( + m_filterHostFrame.layout.treeRect, + inputEvents, + UIEditorHostedTreeViewInputOptions{ + .allowInteraction = dispatchEntry.allowInteraction, + .hasInputFocus = + dispatchEntry.focused && + !IsUIEditorFilterableTreeHostSearchFocused(m_filterHostState), + .captureActive = HasActivePointerCapture() + }, + dispatchEntry.focusGained, + dispatchEntry.focusLost); const std::vector interactionEvents = TreeDrag::BuildInteractionInputEvents( m_dragState, layout, - m_treeItems, - filteredEvents, + presentedItems, + treeEvents, kDragThreshold); m_treeFrame = UpdateUIEditorTreeViewInteraction( m_treeInteractionState, m_treeSelection, - m_expansion, - dispatchEntry.bounds, - m_treeItems, + *activeExpansionModel, + m_filterHostFrame.layout.treeRect, + presentedItems, interactionEvents, treeMetrics); if (m_treeFrame.result.renameRequested && @@ -580,23 +641,34 @@ void HierarchyPanel::Update( ProcessDragAndFrameEvents( inputEvents, - dispatchEntry.bounds, + m_filterHostFrame.layout.treeRect, dispatchEntry); } void HierarchyPanel::Append(UIDrawList& drawList) const { if (!m_visible || - m_treeFrame.layout.bounds.width <= 0.0f || - m_treeFrame.layout.bounds.height <= 0.0f) { + m_filterHostFrame.layout.bounds.width <= 0.0f || + m_filterHostFrame.layout.bounds.height <= 0.0f) { return; } + const std::vector& presentedItems = + GetPresentedTreeItems(); const Widgets::UIEditorTreeViewPalette palette = ResolveUIEditorTreeViewPalette(); const Widgets::UIEditorTreeViewMetrics metrics = ResolveUIEditorTreeViewMetrics(); + drawList.AddFilledRect(m_filterHostFrame.layout.bounds, palette.surfaceColor); + AppendUIEditorFilterableTreeHostSearchField( + drawList, + m_filterHostFrame, + m_filterHostState); + if (m_treeFrame.layout.bounds.width <= 0.0f || + m_treeFrame.layout.bounds.height <= 0.0f) { + return; + } AppendUIEditorTreeViewBackground( drawList, m_treeFrame.layout, - m_treeItems, + presentedItems, m_treeSelection, m_treeInteractionState.treeViewState, palette, @@ -604,7 +676,7 @@ void HierarchyPanel::Append(UIDrawList& drawList) const { AppendUIEditorTreeViewForeground( drawList, m_treeFrame.layout, - m_treeItems, + presentedItems, palette, metrics); @@ -621,7 +693,7 @@ void HierarchyPanel::Append(UIDrawList& drawList) const { AppendUIEditorTreeViewDropPreview( drawList, m_treeFrame.layout, - m_treeItems, + presentedItems, m_dragState.dragging && m_dragState.validDropTarget, m_dragState.dropToRoot, m_dragState.dropTargetItemId, diff --git a/new_editor/app/Features/Hierarchy/HierarchyPanel.h b/new_editor/app/Features/Hierarchy/HierarchyPanel.h index 8f9431c0..c5946967 100644 --- a/new_editor/app/Features/Hierarchy/HierarchyPanel.h +++ b/new_editor/app/Features/Hierarchy/HierarchyPanel.h @@ -3,6 +3,7 @@ #include "HierarchyModel.h" #include "Commands/EditorEditCommandRoute.h" +#include #include #include #include @@ -84,6 +85,8 @@ private: ::XCEngine::UI::UIRect BuildRenameBounds( std::string_view itemId, const Widgets::UIEditorTreeViewLayout& layout) const; + const std::vector& GetPresentedTreeItems() const; + const ::XCEngine::UI::Widgets::UIExpansionModel& GetPresentedExpansionModel() const; const BuiltInIcons* m_icons = nullptr; EditorCommandFocusService* m_commandFocusService = nullptr; @@ -92,6 +95,8 @@ private: std::vector m_treeItems = {}; ::XCEngine::UI::Widgets::UISelectionModel m_treeSelection = {}; ::XCEngine::UI::Widgets::UIExpansionModel m_expansion = {}; + UIEditorFilterableTreeHostState m_filterHostState = {}; + UIEditorFilterableTreeHostFrame m_filterHostFrame = {}; UIEditorTreeViewInteractionState m_treeInteractionState = {}; UIEditorTreeViewInteractionFrame m_treeFrame = {}; UIEditorInlineRenameSessionState m_renameState = {}; diff --git a/new_editor/app/Features/Project/ProjectPanel.cpp b/new_editor/app/Features/Project/ProjectPanel.cpp index 70798990..25b6cdf4 100644 --- a/new_editor/app/Features/Project/ProjectPanel.cpp +++ b/new_editor/app/Features/Project/ProjectPanel.cpp @@ -341,6 +341,15 @@ const std::vector& ProjectPanel::GetWindowTreeIte return m_windowTreeItems; } +const std::vector& ProjectPanel::GetPresentedWindowTreeItems() const { + return ResolveUIEditorFilterableTreeHostItems(m_treeFilterHostFrame); +} + +const ::XCEngine::UI::Widgets::UIExpansionModel& +ProjectPanel::GetPresentedWindowTreeExpansionModel() const { + return ResolveUIEditorFilterableTreeHostExpansionModel(m_treeFilterHostFrame); +} + void ProjectPanel::Initialize(const std::filesystem::path& repoRoot) { m_ownedProjectRuntime = std::make_unique(); m_ownedProjectRuntime->Initialize(repoRoot); @@ -378,6 +387,8 @@ void ProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) { void ProjectPanel::ResetInteractionState() { m_assetDragState = {}; m_treeDragState = {}; + m_treeFilterHostState = {}; + m_treeFilterHostFrame = {}; m_browserScrollInteractionState = {}; m_browserScrollFrame = {}; m_browserVerticalOffset = 0.0f; @@ -505,7 +516,7 @@ UIRect ProjectPanel::BuildRenameBounds( if (surface == RenameSurface::Tree) { return BuildUIEditorTreeViewInlineRenameBounds( m_treeFrame.layout, - GetWindowTreeItems(), + GetPresentedWindowTreeItems(), itemId, hostedMetrics); } @@ -690,13 +701,43 @@ void ProjectPanel::SyncAssetSelectionFromRuntime() { m_assetSelection.ClearSelection(); } -Widgets::UIEditorTreeViewMetrics ProjectPanel::RebuildPanelLayout(const UIRect& bounds) { +void ProjectPanel::ApplyBrowserLayout( + const UIRect& bounds, + const UIRect& browserContentRect, + float browserVerticalOffset) { + m_layout = BuildLayout(bounds, browserContentRect, browserVerticalOffset); + if (HasValidBounds(m_treeFilterHostFrame.layout.bounds)) { + m_layout.treeRect = m_treeFilterHostFrame.layout.treeRect; + } +} + +Widgets::UIEditorTreeViewMetrics ProjectPanel::RebuildPanelLayout( + const UIRect& bounds, + const std::vector& treeHostInputEvents) { const Widgets::UIEditorTreeViewMetrics treeMetrics = ResolveUIEditorTreeViewMetrics(); m_layout = BuildLayout(bounds, {}, 0.0f); + m_treeFilterHostFrame = UpdateUIEditorFilterableTreeHost( + m_treeFilterHostState, + m_layout.treeRect, + "Project.TreeFilter", + treeHostInputEvents, + m_windowTreeItems, + m_folderExpansion); + if (m_treeFilterHostFrame.result.queryChanged) { + m_treeInteractionState.verticalOffset = 0.0f; + } + if (IsUIEditorFilterableTreeHostSearchFocused(m_treeFilterHostState)) { + m_treeInteractionState.treeViewState.focused = false; + } + m_layout.treeRect = m_treeFilterHostFrame.layout.treeRect; + ::XCEngine::UI::Widgets::UIExpansionModel* activeExpansionModel = + m_treeFilterHostFrame.result.filteringActive + ? &m_treeFilterHostFrame.filteredExpansionModel + : &m_folderExpansion; m_treeFrame.layout = Widgets::BuildUIEditorTreeViewLayout( m_layout.treeRect, - GetWindowTreeItems(), - m_folderExpansion, + GetPresentedWindowTreeItems(), + *activeExpansionModel, treeMetrics, m_treeInteractionState.verticalOffset); return treeMetrics; @@ -797,7 +838,7 @@ bool ProjectPanel::OpenProjectItem(std::string_view itemId, EventSource source) SyncSelectionsFromRuntime(); RebuildPanelLayout(m_layout.bounds); RebuildBrowserScrollLayout(); - m_layout = BuildLayout( + ApplyBrowserLayout( m_layout.bounds, m_browserScrollFrame.layout.contentRect, m_browserVerticalOffset); @@ -1146,13 +1187,17 @@ std::vector ProjectPanel::BuildTreeInteractionInputEvents( const std::vector& inputEvents, const UIRect& bounds, const UIEditorHostedPanelDispatchEntry& dispatchEntry) const { + const std::vector& presentedItems = + GetPresentedWindowTreeItems(); const std::vector rawEvents = BuildUIEditorHostedTreeViewInputEvents( bounds, inputEvents, UIEditorHostedTreeViewInputOptions{ .allowInteraction = dispatchEntry.allowInteraction, - .hasInputFocus = dispatchEntry.focused, + .hasInputFocus = + dispatchEntry.focused && + !IsUIEditorFilterableTreeHostSearchFocused(m_treeFilterHostState), .captureActive = HasActivePointerCapture() }, dispatchEntry.focusGained, @@ -1162,15 +1207,15 @@ std::vector ProjectPanel::BuildTreeInteractionInputEvents( m_treeFrame.layout.bounds.width > 0.0f ? m_treeFrame.layout : Widgets::BuildUIEditorTreeViewLayout( - m_layout.treeRect, - GetWindowTreeItems(), - m_folderExpansion, + bounds, + presentedItems, + GetPresentedWindowTreeExpansionModel(), ResolveUIEditorTreeViewMetrics(), m_treeInteractionState.verticalOffset); return TreeDrag::BuildInteractionInputEvents( m_treeDragState, layout, - GetWindowTreeItems(), + presentedItems, rawEvents, TreeDrag::kDefaultDragThreshold, m_splitterDragging || m_assetDragState.dragging); @@ -1296,7 +1341,7 @@ UIEditorHostCommandDispatchResult ProjectPanel::DispatchAssetCommand( if (HasValidBounds(m_layout.bounds)) { RebuildPanelLayout(m_layout.bounds); RebuildBrowserScrollLayout(); - m_layout = BuildLayout( + ApplyBrowserLayout( m_layout.bounds, m_browserScrollFrame.layout.contentRect, m_browserVerticalOffset); @@ -1333,7 +1378,7 @@ UIEditorHostCommandDispatchResult ProjectPanel::DispatchAssetCommand( if (HasValidBounds(m_layout.bounds)) { RebuildPanelLayout(m_layout.bounds); RebuildBrowserScrollLayout(); - m_layout = BuildLayout( + ApplyBrowserLayout( m_layout.bounds, m_browserScrollFrame.layout.contentRect, m_browserVerticalOffset); @@ -1591,10 +1636,10 @@ void ProjectPanel::Update( m_navigationWidth = ClampNavigationWidth(m_navigationWidth, dispatchEntry.bounds.width); const Widgets::UIEditorTreeViewMetrics treeMetrics = - RebuildPanelLayout(dispatchEntry.bounds); + RebuildPanelLayout(dispatchEntry.bounds, filteredEvents); const auto refreshBrowserLayout = [&]() { RebuildBrowserScrollLayout(); - m_layout = BuildLayout( + ApplyBrowserLayout( dispatchEntry.bounds, m_browserScrollFrame.layout.contentRect, m_browserVerticalOffset); @@ -1614,7 +1659,7 @@ void ProjectPanel::Update( m_browserVerticalOffset = 0.0f; } - m_layout = BuildLayout( + ApplyBrowserLayout( dispatchEntry.bounds, m_browserScrollFrame.layout.contentRect, m_browserVerticalOffset); @@ -1640,14 +1685,18 @@ void ProjectPanel::Update( const std::vector treeEvents = BuildTreeInteractionInputEvents( inputEvents, - dispatchEntry.bounds, + m_layout.treeRect, dispatchEntry); + ::XCEngine::UI::Widgets::UIExpansionModel* activeExpansionModel = + m_treeFilterHostFrame.result.filteringActive + ? &m_treeFilterHostFrame.filteredExpansionModel + : &m_folderExpansion; m_treeFrame = UpdateUIEditorTreeViewInteraction( m_treeInteractionState, m_folderSelection, - m_folderExpansion, + *activeExpansionModel, m_layout.treeRect, - GetWindowTreeItems(), + GetPresentedWindowTreeItems(), treeEvents, treeMetrics); @@ -1726,7 +1775,7 @@ void ProjectPanel::Update( TreeDrag::ProcessInputEvents( m_treeDragState, m_treeFrame.layout, - GetWindowTreeItems(), + GetPresentedWindowTreeItems(), filteredEvents, m_layout.treeRect, treeDragCallbacks, @@ -2212,15 +2261,17 @@ std::string ProjectPanel::ResolveAssetDropTargetItemId( } if (ContainsPoint(m_treeFrame.layout.bounds, point)) { + const std::vector& presentedItems = + GetPresentedWindowTreeItems(); Widgets::UIEditorTreeViewHitTarget hitTarget = Widgets::HitTestUIEditorTreeView(m_treeFrame.layout, point); - if (hitTarget.itemIndex < GetWindowTreeItems().size() && + if (hitTarget.itemIndex < presentedItems.size() && (hitTarget.kind == Widgets::UIEditorTreeViewHitTargetKind::Row || hitTarget.kind == Widgets::UIEditorTreeViewHitTargetKind::Disclosure)) { if (surface != nullptr) { *surface = DropTargetSurface::Tree; } - return GetWindowTreeItems()[hitTarget.itemIndex].itemId; + return presentedItems[hitTarget.itemIndex].itemId; } } @@ -2249,6 +2300,8 @@ void ProjectPanel::Append(UIDrawList& drawList) const { const BrowserModel& browserModel = GetBrowserModel(); const auto& assetEntries = browserModel.GetAssetEntries(); const std::filesystem::path projectRootPath = browserModel.GetProjectRootPath(); + const std::vector& presentedItems = + GetPresentedWindowTreeItems(); drawList.AddFilledRect(m_layout.bounds, kSurfaceColor); drawList.AddFilledRect(m_layout.leftPaneRect, kPaneColor); @@ -2268,10 +2321,14 @@ void ProjectPanel::Append(UIDrawList& drawList) const { const Widgets::UIEditorTreeViewPalette treePalette = ResolveUIEditorTreeViewPalette(); const Widgets::UIEditorTreeViewMetrics treeMetrics = ResolveUIEditorTreeViewMetrics(); + AppendUIEditorFilterableTreeHostSearchField( + drawList, + m_treeFilterHostFrame, + m_treeFilterHostState); AppendUIEditorTreeViewBackground( drawList, m_treeFrame.layout, - GetWindowTreeItems(), + presentedItems, m_folderSelection, m_treeInteractionState.treeViewState, treePalette, @@ -2279,14 +2336,14 @@ void ProjectPanel::Append(UIDrawList& drawList) const { AppendUIEditorTreeViewForeground( drawList, m_treeFrame.layout, - GetWindowTreeItems(), + presentedItems, treePalette, treeMetrics); AppendUIEditorTreeViewDropPreview( drawList, m_treeFrame.layout, - GetWindowTreeItems(), + presentedItems, m_treeDragState.dragging && m_treeDragState.validDropTarget, m_treeDragState.dropToRoot, m_treeDragState.dropTargetItemId, @@ -2297,7 +2354,7 @@ void ProjectPanel::Append(UIDrawList& drawList) const { AppendUIEditorTreeViewDropPreview( drawList, m_treeFrame.layout, - GetWindowTreeItems(), + presentedItems, m_assetDragState.dragging && m_assetDragState.validDropTarget && m_assetDropTargetSurface == DropTargetSurface::Tree, diff --git a/new_editor/app/Features/Project/ProjectPanel.h b/new_editor/app/Features/Project/ProjectPanel.h index 2613f5aa..9d0a5a9c 100644 --- a/new_editor/app/Features/Project/ProjectPanel.h +++ b/new_editor/app/Features/Project/ProjectPanel.h @@ -4,6 +4,7 @@ #include "ProjectBrowserModel.h" #include "Commands/EditorEditCommandRoute.h" +#include #include #include #include @@ -169,6 +170,8 @@ private: const BrowserModel& GetBrowserModel() const; void RebuildWindowTreeItems(); const std::vector& GetWindowTreeItems() const; + const std::vector& GetPresentedWindowTreeItems() const; + const ::XCEngine::UI::Widgets::UIExpansionModel& GetPresentedWindowTreeExpansionModel() const; const FolderEntry* FindFolderEntry(std::string_view itemId) const; const AssetEntry* FindAssetEntry(std::string_view itemId) const; AssetCommandTarget ResolveAssetCommandTarget( @@ -181,6 +184,10 @@ private: const ::XCEngine::UI::UIRect& bounds, const ::XCEngine::UI::UIRect& browserContentRect, float browserVerticalOffset) const; + void ApplyBrowserLayout( + const ::XCEngine::UI::UIRect& bounds, + const ::XCEngine::UI::UIRect& browserContentRect, + float browserVerticalOffset); std::size_t HitTestBreadcrumbItem(const ::XCEngine::UI::UIPoint& point) const; std::size_t HitTestAssetTile(const ::XCEngine::UI::UIPoint& point) const; std::string ResolveAssetDropTargetItemId( @@ -208,7 +215,8 @@ private: void SyncSelectionsFromRuntime(); void SyncAssetSelectionFromRuntime(); Widgets::UIEditorTreeViewMetrics RebuildPanelLayout( - const ::XCEngine::UI::UIRect& bounds); + const ::XCEngine::UI::UIRect& bounds, + const std::vector<::XCEngine::UI::UIInputEvent>& treeHostInputEvents = {}); void QueueRenameSession( std::string_view itemId, RenameSurface surface); @@ -254,6 +262,8 @@ private: ::XCEngine::UI::Widgets::UISelectionModel m_assetSelection = {}; Collections::GridDragDrop::State m_assetDragState = {}; Collections::TreeDragDrop::State m_treeDragState = {}; + UIEditorFilterableTreeHostState m_treeFilterHostState = {}; + UIEditorFilterableTreeHostFrame m_treeFilterHostFrame = {}; UIEditorScrollViewInteractionState m_browserScrollInteractionState = {}; UIEditorScrollViewInteractionFrame m_browserScrollFrame = {}; float m_browserVerticalOffset = 0.0f; diff --git a/new_editor/include/XCEditor/Collections/UIEditorFilterableTreeHost.h b/new_editor/include/XCEditor/Collections/UIEditorFilterableTreeHost.h new file mode 100644 index 00000000..36655af2 --- /dev/null +++ b/new_editor/include/XCEditor/Collections/UIEditorFilterableTreeHost.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include + +#include + +#include +#include +#include + +namespace XCEngine::UI::Editor::Widgets { + +struct UIEditorFilterableTreeHostMetrics { + float horizontalPadding = 6.0f; + float topPadding = 6.0f; + float bottomPadding = 6.0f; + float searchFieldHeight = 24.0f; + float searchTreeGap = 6.0f; + UIEditorTextFieldMetrics searchFieldMetrics = {}; +}; + +struct UIEditorFilterableTreeHostPalette { + UIEditorTextFieldPalette searchFieldPalette = {}; + ::XCEngine::UI::UIColor placeholderColor = + ::XCEngine::UI::UIColor(0.46f, 0.46f, 0.46f, 1.0f); +}; + +struct UIEditorFilterableTreeHostLayout { + ::XCEngine::UI::UIRect bounds = {}; + ::XCEngine::UI::UIRect searchFieldRect = {}; + ::XCEngine::UI::UIRect treeRect = {}; +}; + +} // namespace XCEngine::UI::Editor::Widgets + +namespace XCEngine::UI::Editor { + +struct UIEditorFilterableTreeHostState { + UIEditorTextFieldInteractionState searchFieldInteractionState = {}; + Widgets::UIEditorTextFieldSpec searchFieldSpec = {}; + std::string normalizedQuery = {}; +}; + +struct UIEditorFilterableTreeHostResult { + bool queryChanged = false; + bool filteringActive = false; + bool searchFocused = false; +}; + +struct UIEditorFilterableTreeHostFrame { + Widgets::UIEditorFilterableTreeHostLayout layout = {}; + UIEditorTextFieldInteractionFrame searchFieldFrame = {}; + const std::vector* sourceItems = nullptr; + const ::XCEngine::UI::Widgets::UIExpansionModel* sourceExpansionModel = nullptr; + std::vector filteredItems = {}; + ::XCEngine::UI::Widgets::UIExpansionModel filteredExpansionModel = {}; + UIEditorFilterableTreeHostResult result = {}; +}; + +inline bool IsUIEditorFilterableTreeHostSearchFocused( + const UIEditorFilterableTreeHostState& state) { + return state.searchFieldInteractionState.textFieldState.focused; +} + +inline const std::vector& +ResolveUIEditorFilterableTreeHostItems( + const UIEditorFilterableTreeHostFrame& frame) { + static const std::vector kEmptyItems = {}; + if (frame.result.filteringActive) { + return frame.filteredItems; + } + return frame.sourceItems != nullptr ? *frame.sourceItems : kEmptyItems; +} + +inline const ::XCEngine::UI::Widgets::UIExpansionModel& +ResolveUIEditorFilterableTreeHostExpansionModel( + const UIEditorFilterableTreeHostFrame& frame) { + static const ::XCEngine::UI::Widgets::UIExpansionModel kEmptyExpansionModel = {}; + if (frame.result.filteringActive) { + return frame.filteredExpansionModel; + } + return frame.sourceExpansionModel != nullptr + ? *frame.sourceExpansionModel + : kEmptyExpansionModel; +} + +UIEditorFilterableTreeHostFrame UpdateUIEditorFilterableTreeHost( + UIEditorFilterableTreeHostState& state, + const ::XCEngine::UI::UIRect& bounds, + std::string_view searchFieldId, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + const std::vector& sourceItems, + const ::XCEngine::UI::Widgets::UIExpansionModel& sourceExpansionModel, + const Widgets::UIEditorFilterableTreeHostMetrics& metrics = {}); + +void AppendUIEditorFilterableTreeHostSearchField( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorFilterableTreeHostFrame& frame, + const UIEditorFilterableTreeHostState& state, + std::string_view placeholderText = "Search", + const Widgets::UIEditorFilterableTreeHostPalette& palette = {}, + const Widgets::UIEditorFilterableTreeHostMetrics& metrics = {}); + +} // namespace XCEngine::UI::Editor diff --git a/new_editor/src/Collections/UIEditorFilterableTreeHost.cpp b/new_editor/src/Collections/UIEditorFilterableTreeHost.cpp new file mode 100644 index 00000000..6c0b5dac --- /dev/null +++ b/new_editor/src/Collections/UIEditorFilterableTreeHost.cpp @@ -0,0 +1,274 @@ +#include + +#include +#include +#include + +namespace XCEngine::UI::Editor { + +namespace { + +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; + +std::string TrimCopy(std::string_view value) { + const auto first = std::find_if_not( + value.begin(), + value.end(), + [](unsigned char character) { + return std::isspace(character) != 0; + }); + if (first == value.end()) { + return {}; + } + + const auto last = std::find_if_not( + value.rbegin(), + value.rend(), + [](unsigned char character) { + return std::isspace(character) != 0; + }).base(); + return std::string(first, last); +} + +std::string ToLowerCopy(std::string value) { + std::transform( + value.begin(), + value.end(), + value.begin(), + [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return value; +} + +std::string NormalizeQuery(std::string_view value) { + return ToLowerCopy(TrimCopy(value)); +} + +Widgets::UIEditorFilterableTreeHostMetrics ResolveMetrics( + const Widgets::UIEditorFilterableTreeHostMetrics& metrics) { + Widgets::UIEditorFilterableTreeHostMetrics resolved = metrics; + resolved.searchFieldMetrics.rowHeight = metrics.searchFieldHeight; + resolved.searchFieldMetrics.horizontalPadding = 0.0f; + resolved.searchFieldMetrics.labelControlGap = 0.0f; + resolved.searchFieldMetrics.controlColumnStart = 0.0f; + resolved.searchFieldMetrics.controlTrailingInset = 0.0f; + resolved.searchFieldMetrics.valueBoxMinWidth = 0.0f; + resolved.searchFieldMetrics.controlInsetY = 1.0f; + resolved.searchFieldMetrics.valueTextInsetX = 8.0f; + resolved.searchFieldMetrics.valueTextInsetY = -1.0f; + resolved.searchFieldMetrics.valueFontSize = 12.0f; + resolved.searchFieldMetrics.valueBoxRounding = 4.0f; + resolved.searchFieldMetrics.cornerRounding = 0.0f; + return resolved; +} + +Widgets::UIEditorFilterableTreeHostLayout BuildLayout( + const UIRect& bounds, + const Widgets::UIEditorFilterableTreeHostMetrics& metrics) { + Widgets::UIEditorFilterableTreeHostLayout layout = {}; + layout.bounds = bounds; + + const float width = (std::max)(bounds.width, 0.0f); + const float height = (std::max)(bounds.height, 0.0f); + if (width <= 0.0f || height <= 0.0f) { + return layout; + } + + const float searchX = bounds.x + metrics.horizontalPadding; + const float searchY = bounds.y + metrics.topPadding; + const float searchWidth = + (std::max)(width - metrics.horizontalPadding * 2.0f, 0.0f); + const float searchHeight = + (std::min)(metrics.searchFieldHeight, (std::max)(height - metrics.topPadding, 0.0f)); + layout.searchFieldRect = UIRect(searchX, searchY, searchWidth, searchHeight); + + const float treeY = searchY + searchHeight + metrics.searchTreeGap; + const float treeHeight = + (std::max)(bounds.y + height - metrics.bottomPadding - treeY, 0.0f); + layout.treeRect = UIRect(bounds.x, treeY, width, treeHeight); + return layout; +} + +std::vector BuildParentIndices( + const std::vector& items) { + std::vector parentIndices( + items.size(), + Widgets::UIEditorTreeViewInvalidIndex); + std::vector stack = {}; + stack.reserve(items.size()); + + for (std::size_t itemIndex = 0u; itemIndex < items.size(); ++itemIndex) { + while (!stack.empty() && + items[stack.back()].depth >= items[itemIndex].depth) { + stack.pop_back(); + } + + if (!stack.empty()) { + parentIndices[itemIndex] = stack.back(); + } + + stack.push_back(itemIndex); + } + + return parentIndices; +} + +void BuildFilteredItems( + const std::vector& sourceItems, + std::string_view normalizedQuery, + std::vector& filteredItems, + ::XCEngine::UI::Widgets::UIExpansionModel& expansionModel) { + filteredItems.clear(); + expansionModel.Clear(); + + if (normalizedQuery.empty()) { + return; + } + + const std::vector parentIndices = + BuildParentIndices(sourceItems); + std::vector retained(sourceItems.size(), false); + for (std::size_t itemIndex = 0u; itemIndex < sourceItems.size(); ++itemIndex) { + const std::string normalizedLabel = + NormalizeQuery(sourceItems[itemIndex].label); + if (normalizedLabel.find(normalizedQuery) == std::string::npos) { + continue; + } + + std::size_t currentIndex = itemIndex; + while (currentIndex != Widgets::UIEditorTreeViewInvalidIndex) { + retained[currentIndex] = true; + currentIndex = parentIndices[currentIndex]; + } + } + + filteredItems.reserve(sourceItems.size()); + for (std::size_t itemIndex = 0u; itemIndex < sourceItems.size(); ++itemIndex) { + if (!retained[itemIndex]) { + continue; + } + + filteredItems.push_back(sourceItems[itemIndex]); + } + + for (std::size_t visibleIndex = 0u; visibleIndex < filteredItems.size(); ++visibleIndex) { + const bool hasVisibleDescendant = + visibleIndex + 1u < filteredItems.size() && + filteredItems[visibleIndex + 1u].depth > filteredItems[visibleIndex].depth; + filteredItems[visibleIndex].forceLeaf = !hasVisibleDescendant; + if (hasVisibleDescendant) { + expansionModel.Expand(filteredItems[visibleIndex].itemId); + } + } +} + +float ResolveTextTop(const UIRect& rect, float fontSize, float insetY) { + const float lineHeight = fontSize * 1.6f; + return rect.y + std::floor((rect.height - lineHeight) * 0.5f) + insetY; +} + +} // namespace + +UIEditorFilterableTreeHostFrame UpdateUIEditorFilterableTreeHost( + UIEditorFilterableTreeHostState& state, + const UIRect& bounds, + std::string_view searchFieldId, + const std::vector& inputEvents, + const std::vector& sourceItems, + const ::XCEngine::UI::Widgets::UIExpansionModel& sourceExpansionModel, + const Widgets::UIEditorFilterableTreeHostMetrics& metrics) { + const Widgets::UIEditorFilterableTreeHostMetrics resolvedMetrics = + ResolveMetrics(metrics); + if (state.searchFieldSpec.fieldId != searchFieldId) { + state.searchFieldSpec.fieldId = std::string(searchFieldId); + } + state.searchFieldSpec.label.clear(); + state.searchFieldSpec.readOnly = false; + + const std::string previousNormalizedQuery = state.normalizedQuery; + const Widgets::UIEditorFilterableTreeHostLayout layout = + BuildLayout(bounds, resolvedMetrics); + const UIEditorTextFieldInteractionFrame searchFieldFrame = + UpdateUIEditorTextFieldInteraction( + state.searchFieldInteractionState, + state.searchFieldSpec, + layout.searchFieldRect, + inputEvents, + resolvedMetrics.searchFieldMetrics); + + state.normalizedQuery = NormalizeQuery(state.searchFieldSpec.value); + + UIEditorFilterableTreeHostFrame frame = {}; + frame.layout = layout; + frame.searchFieldFrame = searchFieldFrame; + frame.sourceItems = &sourceItems; + frame.sourceExpansionModel = &sourceExpansionModel; + frame.result.queryChanged = previousNormalizedQuery != state.normalizedQuery; + frame.result.filteringActive = !state.normalizedQuery.empty(); + frame.result.searchFocused = IsUIEditorFilterableTreeHostSearchFocused(state); + + if (frame.result.filteringActive) { + BuildFilteredItems( + sourceItems, + state.normalizedQuery, + frame.filteredItems, + frame.filteredExpansionModel); + } + + return frame; +} + +void AppendUIEditorFilterableTreeHostSearchField( + ::XCEngine::UI::UIDrawList& drawList, + const UIEditorFilterableTreeHostFrame& frame, + const UIEditorFilterableTreeHostState& state, + std::string_view placeholderText, + const Widgets::UIEditorFilterableTreeHostPalette& palette, + const Widgets::UIEditorFilterableTreeHostMetrics& metrics) { + if (frame.layout.searchFieldRect.width <= 0.0f || + frame.layout.searchFieldRect.height <= 0.0f) { + return; + } + + const Widgets::UIEditorFilterableTreeHostMetrics resolvedMetrics = + ResolveMetrics(metrics); + Widgets::AppendUIEditorTextFieldBackground( + drawList, + frame.searchFieldFrame.layout, + state.searchFieldSpec, + state.searchFieldInteractionState.textFieldState, + palette.searchFieldPalette, + resolvedMetrics.searchFieldMetrics); + Widgets::AppendUIEditorTextFieldForeground( + drawList, + frame.searchFieldFrame.layout, + state.searchFieldSpec, + state.searchFieldInteractionState.textFieldState, + palette.searchFieldPalette, + resolvedMetrics.searchFieldMetrics); + + if (!state.searchFieldSpec.value.empty() || + state.searchFieldInteractionState.textFieldState.editing || + placeholderText.empty()) { + return; + } + + const UIRect& valueRect = frame.searchFieldFrame.layout.valueRect; + drawList.PushClipRect(valueRect); + drawList.AddText( + UIPoint( + valueRect.x + resolvedMetrics.searchFieldMetrics.valueTextInsetX, + ResolveTextTop( + valueRect, + resolvedMetrics.searchFieldMetrics.valueFontSize, + resolvedMetrics.searchFieldMetrics.valueTextInsetY)), + std::string(placeholderText), + palette.placeholderColor, + resolvedMetrics.searchFieldMetrics.valueFontSize); + drawList.PopClipRect(); +} + +} // namespace XCEngine::UI::Editor