diff --git a/engine/include/XCEngine/UI/DrawData.h b/engine/include/XCEngine/UI/DrawData.h index a09a9db7..5f12a9e2 100644 --- a/engine/include/XCEngine/UI/DrawData.h +++ b/engine/include/XCEngine/UI/DrawData.h @@ -31,6 +31,7 @@ enum class UIDrawCommandType : std::uint8_t { RectOutline, FilledRectLinearGradient, Line, + FilledTriangle, FilledCircle, CircleOutline, Text, @@ -149,6 +150,20 @@ public: return AddCommand(std::move(command)); } + UIDrawCommand& AddFilledTriangle( + const UIPoint& a, + const UIPoint& b, + const UIPoint& c, + const UIColor& color) { + UIDrawCommand command = {}; + command.type = UIDrawCommandType::FilledTriangle; + command.position = a; + command.uvMin = b; + command.uvMax = c; + command.color = color; + return AddCommand(std::move(command)); + } + UIDrawCommand& AddFilledCircle( const UIPoint& center, float radius, diff --git a/new_editor/CMakeLists.txt b/new_editor/CMakeLists.txt index b93dca3a..00db9081 100644 --- a/new_editor/CMakeLists.txt +++ b/new_editor/CMakeLists.txt @@ -147,6 +147,7 @@ if(XCENGINE_BUILD_XCUI_EDITOR_APP) add_executable(XCUIEditorApp WIN32 app/main.cpp app/Application.cpp + app/Panels/ProductProjectPanel.cpp app/Shell/ProductShellAsset.cpp ) diff --git a/new_editor/Host/NativeRenderer.cpp b/new_editor/Host/NativeRenderer.cpp index fc5d872d..bcc885b8 100644 --- a/new_editor/Host/NativeRenderer.cpp +++ b/new_editor/Host/NativeRenderer.cpp @@ -38,6 +38,20 @@ D2D1_RECT_F ToD2DRect(const ::XCEngine::UI::UIRect& rect, float dpiScale) { return D2D1::RectF(left, top, right, bottom); } +D2D1_POINT_2F ToD2DPoint( + const ::XCEngine::UI::UIPoint& point, + float dpiScale, + float pixelOffset = 0.0f) { + return D2D1::Point2F( + SnapToPixel(point.x, dpiScale) + pixelOffset, + SnapToPixel(point.y, dpiScale) + pixelOffset); +} + +float ResolveStrokePixelOffset(float thickness) { + const float roundedThickness = std::round(thickness); + return std::fmod(roundedThickness, 2.0f) == 1.0f ? 0.5f : 0.0f; +} + } // namespace bool NativeRenderer::Initialize(HWND hwnd) { @@ -458,6 +472,51 @@ void NativeRenderer::RenderCommand( } break; } + case ::XCEngine::UI::UIDrawCommandType::FilledRectLinearGradient: { + const D2D1_RECT_F rect = ToD2DRect(command.rect, dpiScale); + const float rounding = command.rounding > 0.0f ? command.rounding * dpiScale : 0.0f; + + const D2D1_GRADIENT_STOP stops[2] = { + D2D1::GradientStop(0.0f, ToD2DColor(command.color)), + D2D1::GradientStop(1.0f, ToD2DColor(command.secondaryColor)) + }; + + Microsoft::WRL::ComPtr stopCollection; + HRESULT hr = renderTarget.CreateGradientStopCollection( + stops, + 2u, + stopCollection.ReleaseAndGetAddressOf()); + if (FAILED(hr) || !stopCollection) { + break; + } + + const D2D1_POINT_2F startPoint = + command.gradientDirection == ::XCEngine::UI::UILinearGradientDirection::Vertical + ? D2D1::Point2F((rect.left + rect.right) * 0.5f, rect.top) + : D2D1::Point2F(rect.left, (rect.top + rect.bottom) * 0.5f); + const D2D1_POINT_2F endPoint = + command.gradientDirection == ::XCEngine::UI::UILinearGradientDirection::Vertical + ? D2D1::Point2F((rect.left + rect.right) * 0.5f, rect.bottom) + : D2D1::Point2F(rect.right, (rect.top + rect.bottom) * 0.5f); + + Microsoft::WRL::ComPtr gradientBrush; + hr = renderTarget.CreateLinearGradientBrush( + D2D1::LinearGradientBrushProperties(startPoint, endPoint), + stopCollection.Get(), + gradientBrush.ReleaseAndGetAddressOf()); + if (FAILED(hr) || !gradientBrush) { + break; + } + + if (command.rounding > 0.0f) { + renderTarget.FillRoundedRectangle( + D2D1::RoundedRect(rect, rounding, rounding), + gradientBrush.Get()); + } else { + renderTarget.FillRectangle(rect, gradientBrush.Get()); + } + break; + } case ::XCEngine::UI::UIDrawCommandType::RectOutline: { const D2D1_RECT_F rect = ToD2DRect(command.rect, dpiScale); const float thickness = (command.thickness > 0.0f ? command.thickness : 1.0f) * dpiScale; @@ -472,6 +531,58 @@ void NativeRenderer::RenderCommand( } break; } + case ::XCEngine::UI::UIDrawCommandType::Line: { + const float thickness = (command.thickness > 0.0f ? command.thickness : 1.0f) * dpiScale; + const float pixelOffset = ResolveStrokePixelOffset(thickness); + const D2D1_POINT_2F start = ToD2DPoint(command.position, dpiScale, pixelOffset); + const D2D1_POINT_2F end = ToD2DPoint(command.uvMin, dpiScale, pixelOffset); + renderTarget.DrawLine(start, end, &solidBrush, thickness); + break; + } + case ::XCEngine::UI::UIDrawCommandType::FilledTriangle: { + Microsoft::WRL::ComPtr geometry; + HRESULT hr = m_d2dFactory->CreatePathGeometry(geometry.ReleaseAndGetAddressOf()); + if (FAILED(hr) || !geometry) { + break; + } + + Microsoft::WRL::ComPtr sink; + hr = geometry->Open(sink.ReleaseAndGetAddressOf()); + if (FAILED(hr) || !sink) { + break; + } + + const D2D1_POINT_2F a = ToD2DPoint(command.position, dpiScale); + const D2D1_POINT_2F b = ToD2DPoint(command.uvMin, dpiScale); + const D2D1_POINT_2F c = ToD2DPoint(command.uvMax, dpiScale); + const D2D1_POINT_2F points[2] = { b, c }; + sink->BeginFigure(a, D2D1_FIGURE_BEGIN_FILLED); + sink->AddLines(points, 2u); + sink->EndFigure(D2D1_FIGURE_END_CLOSED); + hr = sink->Close(); + if (FAILED(hr)) { + break; + } + + renderTarget.FillGeometry(geometry.Get(), &solidBrush); + break; + } + case ::XCEngine::UI::UIDrawCommandType::FilledCircle: { + const float radius = command.radius * dpiScale; + renderTarget.FillEllipse( + D2D1::Ellipse(ToD2DPoint(command.position, dpiScale), radius, radius), + &solidBrush); + break; + } + case ::XCEngine::UI::UIDrawCommandType::CircleOutline: { + const float radius = command.radius * dpiScale; + const float thickness = (command.thickness > 0.0f ? command.thickness : 1.0f) * dpiScale; + renderTarget.DrawEllipse( + D2D1::Ellipse(ToD2DPoint(command.position, dpiScale), radius, radius), + &solidBrush, + thickness); + break; + } case ::XCEngine::UI::UIDrawCommandType::Text: { if (command.text.empty()) { break; diff --git a/new_editor/app/Application.cpp b/new_editor/app/Application.cpp index 56e95519..cef5c8ca 100644 --- a/new_editor/app/Application.cpp +++ b/new_editor/app/Application.cpp @@ -41,6 +41,45 @@ constexpr const wchar_t* kWindowTitle = L"Main Scene * - Main.xx - XCEngine Edit constexpr UINT kDefaultDpi = 96u; constexpr float kBaseDpiScale = 96.0f; +UIEditorShellComposeModel BuildShellComposeModelFromFrame( + const UIEditorShellInteractionFrame& frame) { + UIEditorShellComposeModel model = {}; + model.menuBarItems = frame.request.menuBarItems; + model.toolbarButtons = frame.model.toolbarButtons; + model.statusSegments = frame.model.statusSegments; + model.workspacePresentations = frame.model.workspacePresentations; + return model; +} + +void AppendShellPopups( + UIDrawList& drawList, + const UIEditorShellInteractionFrame& frame, + const UIEditorShellInteractionPalette& palette, + const UIEditorShellInteractionMetrics& metrics) { + const std::size_t popupCount = + (std::min)(frame.request.popupRequests.size(), frame.popupFrames.size()); + for (std::size_t index = 0; index < popupCount; ++index) { + const UIEditorShellInteractionPopupRequest& popupRequest = + frame.request.popupRequests[index]; + const UIEditorShellInteractionPopupFrame& popupFrame = + frame.popupFrames[index]; + Widgets::AppendUIEditorMenuPopupBackground( + drawList, + popupRequest.layout, + popupRequest.widgetItems, + popupFrame.popupState, + palette.popupPalette, + metrics.popupMetrics); + Widgets::AppendUIEditorMenuPopupForeground( + drawList, + popupRequest.layout, + popupRequest.widgetItems, + popupFrame.popupState, + palette.popupPalette, + metrics.popupMetrics); + } +} + Application* GetApplicationFromWindow(HWND hwnd) { return reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); } @@ -289,6 +328,27 @@ std::string DescribeInputEventType(const UIInputEvent& event) { } } +std::vector FilterShellInputEventsForHostedContentCapture( + const std::vector& inputEvents) { + std::vector filteredEvents = {}; + filteredEvents.reserve(inputEvents.size()); + for (const UIInputEvent& event : inputEvents) { + switch (event.type) { + case UIInputEventType::PointerMove: + case UIInputEventType::PointerEnter: + case UIInputEventType::PointerLeave: + case UIInputEventType::PointerButtonDown: + case UIInputEventType::PointerButtonUp: + case UIInputEventType::PointerWheel: + break; + default: + filteredEvents.push_back(event); + break; + } + } + return filteredEvents; +} + } // namespace int Application::Run(HINSTANCE hInstance, int nCmdShow) { @@ -339,6 +399,7 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { m_shellServices.commandDispatcher = &m_shortcutManager.GetCommandDispatcher(); m_shellServices.shortcutManager = &m_shortcutManager; m_shellServices.textMeasurer = &m_renderer; + m_projectPanel.Initialize(ResolveRepoRootPath()); m_lastStatus = "Ready"; m_lastMessage = "Old editor shell baseline loaded."; LogRuntimeTrace("app", "workspace initialized: " + DescribeWorkspaceState()); @@ -385,6 +446,7 @@ bool Application::Initialize(HINSTANCE hInstance, int nCmdShow) { LogRuntimeTrace("app", "renderer initialization failed"); return false; } + m_projectPanel.SetTextMeasurer(&m_renderer); ShowWindow(m_hwnd, nCmdShow); UpdateWindow(m_hwnd); @@ -448,6 +510,11 @@ void Application::RenderFrame() { const UIEditorShellInteractionDefinition definition = BuildShellDefinition(); std::vector frameEvents = std::move(m_pendingInputEvents); m_pendingInputEvents.clear(); + const std::vector hostedContentEvents = frameEvents; + const std::vector shellEvents = + m_projectPanel.HasActivePointerCapture() + ? FilterShellInputEventsForHostedContentCapture(frameEvents) + : frameEvents; if (!frameEvents.empty()) { LogRuntimeTrace( "input", @@ -459,10 +526,10 @@ void Application::RenderFrame() { m_workspaceController, UIRect(0.0f, 0.0f, width, height), definition, - frameEvents, + shellEvents, m_shellServices, metrics); - if (!frameEvents.empty() || + if (!shellEvents.empty() || m_shellFrame.result.workspaceResult.dockHostResult.layoutChanged || m_shellFrame.result.workspaceResult.dockHostResult.commandExecuted) { std::ostringstream frameTrace = {}; @@ -482,13 +549,24 @@ void Application::RenderFrame() { } ApplyHostCaptureRequests(m_shellFrame.result); UpdateLastStatus(m_shellFrame.result); + m_projectPanel.Update( + m_shellFrame.workspaceInteractionFrame.composeFrame.contentHostFrame, + hostedContentEvents, + !m_shellFrame.result.workspaceInputSuppressed, + m_workspaceController.GetWorkspace().activePanelId == "project"); + ApplyHostedContentCaptureRequests(); ApplyCurrentCursor(); - AppendUIEditorShellInteraction( + const UIEditorShellComposeModel shellComposeModel = + BuildShellComposeModelFromFrame(m_shellFrame); + AppendUIEditorShellCompose( drawList, - m_shellFrame, - m_shellInteractionState, - palette, - metrics); + m_shellFrame.shellFrame, + shellComposeModel, + m_shellInteractionState.composeState, + palette.shellPalette, + metrics.shellMetrics); + m_projectPanel.Append(drawList); + AppendShellPopups(drawList, m_shellFrame, palette, metrics); } else { drawList.AddText( UIPoint(28.0f, 28.0f), @@ -522,6 +600,14 @@ float Application::PixelsToDips(float pixels) const { } LPCWSTR Application::ResolveCurrentCursorResource() const { + switch (m_projectPanel.GetCursorKind()) { + case App::ProductProjectPanel::CursorKind::ResizeEW: + return IDC_SIZEWE; + case App::ProductProjectPanel::CursorKind::Arrow: + default: + break; + } + switch (Widgets::ResolveUIEditorDockHostCursorKind( m_shellFrame.workspaceInteractionFrame.dockHostFrame.layout)) { case Widgets::UIEditorDockHostCursorKind::ResizeEW: @@ -639,7 +725,19 @@ void Application::ApplyHostCaptureRequests(const UIEditorShellInteractionResult& } } -bool Application::HasInteractiveCaptureState() const { +void Application::ApplyHostedContentCaptureRequests() { + if (m_projectPanel.WantsHostPointerCapture() && GetCapture() != m_hwnd) { + SetCapture(m_hwnd); + } + + if (m_projectPanel.WantsHostPointerRelease() && + GetCapture() == m_hwnd && + !HasShellInteractiveCaptureState()) { + ReleaseCapture(); + } +} + +bool Application::HasShellInteractiveCaptureState() const { if (m_shellInteractionState.workspaceInteractionState.dockHostInteractionState.splitterDragState.active) { return true; } @@ -657,6 +755,10 @@ bool Application::HasInteractiveCaptureState() const { return false; } +bool Application::HasInteractiveCaptureState() const { + return HasShellInteractiveCaptureState() || m_projectPanel.HasActivePointerCapture(); +} + UIEditorShellInteractionDefinition Application::BuildShellDefinition() const { std::string statusText = m_lastStatus; if (!m_lastMessage.empty()) { diff --git a/new_editor/app/Application.h b/new_editor/app/Application.h index d8469470..6629c8cc 100644 --- a/new_editor/app/Application.h +++ b/new_editor/app/Application.h @@ -8,6 +8,8 @@ #include #include +#include "Panels/ProductProjectPanel.h" + #include #include #include @@ -43,11 +45,15 @@ private: void RenderFrame(); void OnResize(UINT width, UINT height); void OnDpiChanged(UINT dpi, const RECT& suggestedRect); + bool ApplyCurrentCursor() const; + LPCWSTR ResolveCurrentCursorResource() const; float GetDpiScale() const; float PixelsToDips(float pixels) const; ::XCEngine::UI::UIPoint ConvertClientPixelsToDips(LONG x, LONG y) const; void LogRuntimeTrace(std::string_view channel, std::string_view message) const; void ApplyHostCaptureRequests(const UIEditorShellInteractionResult& result); + void ApplyHostedContentCaptureRequests(); + bool HasShellInteractiveCaptureState() const; bool HasInteractiveCaptureState() const; UIEditorShellInteractionDefinition BuildShellDefinition() const; void UpdateLastStatus(const UIEditorShellInteractionResult& result); @@ -77,6 +83,7 @@ private: EditorShellAssetValidationResult m_shellValidation = {}; UIEditorWorkspaceController m_workspaceController = {}; UIEditorShortcutManager m_shortcutManager = {}; + App::ProductProjectPanel m_projectPanel = {}; UIEditorShellInteractionServices m_shellServices = {}; UIEditorShellInteractionState m_shellInteractionState = {}; UIEditorShellInteractionFrame m_shellFrame = {}; diff --git a/new_editor/app/Panels/ProductProjectPanel.cpp b/new_editor/app/Panels/ProductProjectPanel.cpp new file mode 100644 index 00000000..66656b55 --- /dev/null +++ b/new_editor/app/Panels/ProductProjectPanel.cpp @@ -0,0 +1,1032 @@ +#include "ProductProjectPanel.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace XCEngine::UI::Editor::App { + +namespace { + +using ::XCEngine::UI::Editor::UIEditorTextMeasureRequest; +using ::XCEngine::UI::Editor::UIEditorTextMeasurer; +using ::XCEngine::UI::UIColor; +using ::XCEngine::UI::UIDrawList; +using ::XCEngine::UI::UIInputEvent; +using ::XCEngine::UI::UIInputEventType; +using ::XCEngine::UI::UIPoint; +using ::XCEngine::UI::UIRect; +using Widgets::AppendUIEditorTreeViewBackground; +using Widgets::AppendUIEditorTreeViewForeground; +using Widgets::UIEditorTreeViewMetrics; +using Widgets::UIEditorTreeViewPalette; + +constexpr std::string_view kProjectPanelId = "project"; +constexpr std::string_view kAssetsRootId = "Assets"; +constexpr std::size_t kInvalidLayoutIndex = static_cast(-1); + +constexpr float kBrowserHeaderHeight = 28.0f; +constexpr float kNavigationMinWidth = 180.0f; +constexpr float kBrowserMinWidth = 260.0f; +constexpr float kHeaderHorizontalPadding = 10.0f; +constexpr float kHeaderBottomBorderThickness = 1.0f; +constexpr float kBreadcrumbItemPaddingX = 4.0f; +constexpr float kBreadcrumbItemPaddingY = 1.0f; +constexpr float kBreadcrumbSpacing = 3.0f; +constexpr float kTreeTopPadding = 0.0f; +constexpr float kGridInsetX = 16.0f; +constexpr float kGridInsetY = 12.0f; +constexpr float kGridTileWidth = 92.0f; +constexpr float kGridTileHeight = 92.0f; +constexpr float kGridTileGapX = 12.0f; +constexpr float kGridTileGapY = 12.0f; +constexpr float kGridPreviewWidth = 68.0f; +constexpr float kGridPreviewHeight = 54.0f; +constexpr float kHeaderFontSize = 12.0f; +constexpr float kTileLabelFontSize = 11.0f; + +constexpr UIColor kSurfaceColor(0.200f, 0.200f, 0.200f, 1.0f); +constexpr UIColor kPaneColor(0.205f, 0.205f, 0.205f, 1.0f); +constexpr UIColor kHeaderColor(0.215f, 0.215f, 0.215f, 1.0f); +constexpr UIColor kTextPrimary(0.830f, 0.830f, 0.830f, 1.0f); +constexpr UIColor kTextStrong(0.910f, 0.910f, 0.910f, 1.0f); +constexpr UIColor kTextMuted(0.560f, 0.560f, 0.560f, 1.0f); +constexpr UIColor kTileHoverColor(0.245f, 0.245f, 0.245f, 1.0f); +constexpr UIColor kTileSelectedColor(0.300f, 0.300f, 0.300f, 1.0f); +constexpr UIColor kTilePreviewFillColor(0.700f, 0.700f, 0.700f, 1.0f); +constexpr UIColor kTilePreviewShadeColor(0.610f, 0.610f, 0.610f, 1.0f); +constexpr UIColor kTilePreviewOutlineColor(0.860f, 0.860f, 0.860f, 0.35f); + +bool ContainsPoint(const UIRect& rect, const UIPoint& point) { + return point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height; +} + +float ClampNonNegative(float value) { + return (std::max)(value, 0.0f); +} + +float ResolveTextTop(float rectY, float rectHeight, float fontSize) { + const float lineHeight = fontSize * 1.6f; + return rectY + std::floor((rectHeight - lineHeight) * 0.5f); +} + +float MeasureTextWidth( + const UIEditorTextMeasurer* textMeasurer, + std::string_view text, + float fontSize) { + if (text.empty()) { + return 0.0f; + } + + if (textMeasurer != nullptr) { + const float measuredWidth = + textMeasurer->MeasureTextWidth(UIEditorTextMeasureRequest{ text, fontSize }); + if (measuredWidth > 0.0f) { + return measuredWidth; + } + } + + return static_cast(text.size()) * fontSize * 0.56f; +} + +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 WideToUtf8(std::wstring_view value) { + if (value.empty()) { + return {}; + } + + const int requiredSize = WideCharToMultiByte( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + nullptr, + 0, + nullptr, + nullptr); + if (requiredSize <= 0) { + return {}; + } + + std::string result(static_cast(requiredSize), '\0'); + WideCharToMultiByte( + CP_UTF8, + 0, + value.data(), + static_cast(value.size()), + result.data(), + requiredSize, + nullptr, + nullptr); + return result; +} + +std::string PathToUtf8String(const std::filesystem::path& path) { + return WideToUtf8(path.native()); +} + +std::string NormalizePathSeparators(std::string value) { + std::replace(value.begin(), value.end(), '\\', '/'); + return value; +} + +std::string BuildRelativeItemId( + const std::filesystem::path& path, + const std::filesystem::path& assetsRoot) { + const std::filesystem::path relative = + std::filesystem::relative(path, assetsRoot.parent_path()); + const std::string normalized = + NormalizePathSeparators(PathToUtf8String(relative.lexically_normal())); + return normalized.empty() ? std::string(kAssetsRootId) : normalized; +} + +std::string BuildAssetDisplayName(const std::filesystem::path& path, bool directory) { + if (directory) { + return PathToUtf8String(path.filename()); + } + + const std::string filename = PathToUtf8String(path.filename()); + const std::size_t extensionOffset = filename.find_last_of('.'); + if (extensionOffset == std::string::npos || extensionOffset == 0u) { + return filename; + } + + return filename.substr(0u, extensionOffset); +} + +bool IsMetaFile(const std::filesystem::path& path) { + return ToLowerCopy(path.extension().string()) == ".meta"; +} + +bool HasChildDirectories(const std::filesystem::path& folderPath) { + std::error_code errorCode = {}; + const std::filesystem::directory_iterator end = {}; + for (std::filesystem::directory_iterator iterator(folderPath, errorCode); + !errorCode && iterator != end; + iterator.increment(errorCode)) { + if (iterator->is_directory(errorCode)) { + return true; + } + } + + return false; +} + +std::vector CollectSortedChildDirectories( + const std::filesystem::path& folderPath) { + std::vector paths = {}; + std::error_code errorCode = {}; + const std::filesystem::directory_iterator end = {}; + for (std::filesystem::directory_iterator iterator(folderPath, errorCode); + !errorCode && iterator != end; + iterator.increment(errorCode)) { + if (iterator->is_directory(errorCode)) { + paths.push_back(iterator->path()); + } + } + + std::sort( + paths.begin(), + paths.end(), + [](const std::filesystem::path& lhs, const std::filesystem::path& rhs) { + return ToLowerCopy(PathToUtf8String(lhs.filename())) < + ToLowerCopy(PathToUtf8String(rhs.filename())); + }); + return paths; +} + +std::vector FilterProjectPanelInputEvents( + const UIRect& bounds, + const std::vector& inputEvents, + bool allowInteraction, + bool panelActive, + bool captureActive) { + if (!allowInteraction && !captureActive) { + return {}; + } + + std::vector filteredEvents = {}; + filteredEvents.reserve(inputEvents.size()); + for (const UIInputEvent& event : inputEvents) { + switch (event.type) { + case UIInputEventType::PointerMove: + case UIInputEventType::PointerButtonDown: + case UIInputEventType::PointerButtonUp: + case UIInputEventType::PointerWheel: + if (captureActive || ContainsPoint(bounds, event.position)) { + filteredEvents.push_back(event); + } + break; + case UIInputEventType::PointerLeave: + filteredEvents.push_back(event); + break; + case UIInputEventType::FocusGained: + case UIInputEventType::FocusLost: + if (panelActive || captureActive) { + filteredEvents.push_back(event); + } + break; + case UIInputEventType::KeyDown: + case UIInputEventType::KeyUp: + case UIInputEventType::Character: + if (panelActive) { + filteredEvents.push_back(event); + } + break; + default: + break; + } + } + + return filteredEvents; +} + +std::vector FilterTreeInputEvents( + const std::vector& inputEvents, + bool suppressPointerInput) { + if (!suppressPointerInput) { + return inputEvents; + } + + std::vector filteredEvents = {}; + filteredEvents.reserve(inputEvents.size()); + for (const UIInputEvent& event : inputEvents) { + switch (event.type) { + case UIInputEventType::PointerMove: + case UIInputEventType::PointerButtonDown: + case UIInputEventType::PointerButtonUp: + case UIInputEventType::PointerWheel: + case UIInputEventType::PointerEnter: + break; + default: + filteredEvents.push_back(event); + break; + } + } + return filteredEvents; +} + +UIEditorTreeViewMetrics BuildTreeMetrics() { + UIEditorTreeViewMetrics metrics = {}; + metrics.rowHeight = 20.0f; + metrics.rowGap = 0.0f; + metrics.horizontalPadding = 6.0f; + metrics.indentWidth = 14.0f; + metrics.disclosureExtent = 10.0f; + metrics.disclosureLabelGap = 4.0f; + metrics.labelInsetY = 0.0f; + metrics.cornerRounding = 0.0f; + metrics.borderThickness = 0.0f; + metrics.focusedBorderThickness = 0.0f; + return metrics; +} + +UIEditorTreeViewPalette BuildTreePalette() { + UIEditorTreeViewPalette palette = {}; + palette.surfaceColor = kPaneColor; + palette.borderColor = kPaneColor; + palette.focusedBorderColor = kPaneColor; + palette.rowHoverColor = kTileHoverColor; + palette.rowSelectedColor = kTileSelectedColor; + palette.rowSelectedFocusedColor = kTileSelectedColor; + palette.disclosureColor = kTextMuted; + palette.textColor = kTextPrimary; + return palette; +} + +float ClampNavigationWidth(float value, float totalWidth) { + const float maxWidth = + (std::max)( + kNavigationMinWidth, + totalWidth - kBrowserMinWidth - ResolveUIEditorDockHostMetrics().splitterMetrics.thickness); + return std::clamp(value, kNavigationMinWidth, maxWidth); +} + +std::vector BuildBreadcrumbSegments(std::string_view currentFolderId) { + std::vector segments = {}; + if (currentFolderId.empty()) { + segments.push_back(std::string(kAssetsRootId)); + return segments; + } + + std::size_t segmentStart = 0u; + while (segmentStart < currentFolderId.size()) { + const std::size_t separator = currentFolderId.find('/', segmentStart); + const std::size_t segmentLength = + separator == std::string_view::npos + ? currentFolderId.size() - segmentStart + : separator - segmentStart; + if (segmentLength > 0u) { + segments.emplace_back(currentFolderId.substr(segmentStart, segmentLength)); + } + if (separator == std::string_view::npos) { + break; + } + segmentStart = separator + 1u; + } + + if (segments.empty()) { + segments.push_back(std::string(kAssetsRootId)); + } + return segments; +} + +void AppendTilePreview( + UIDrawList& drawList, + const UIRect& previewRect, + bool directory) { + if (directory) { + const UIRect tabRect( + previewRect.x + 8.0f, + previewRect.y + 7.0f, + 22.0f, + 7.0f); + const UIRect bodyRect( + previewRect.x + 4.0f, + previewRect.y + 13.0f, + previewRect.width - 8.0f, + previewRect.height - 18.0f); + drawList.AddFilledRect(tabRect, kTilePreviewShadeColor, 1.0f); + drawList.AddFilledRect(bodyRect, kTilePreviewFillColor, 1.0f); + drawList.AddRectOutline(bodyRect, kTilePreviewOutlineColor, 1.0f, 1.0f); + return; + } + + const UIRect sheetRect( + previewRect.x + 10.0f, + previewRect.y + 4.0f, + previewRect.width - 20.0f, + previewRect.height - 8.0f); + const UIRect accentRect( + sheetRect.x, + sheetRect.y, + sheetRect.width, + 7.0f); + drawList.AddFilledRect(sheetRect, kTilePreviewFillColor, 1.0f); + drawList.AddFilledRect(accentRect, kTilePreviewShadeColor, 1.0f); + drawList.AddRectOutline(sheetRect, kTilePreviewOutlineColor, 1.0f, 1.0f); +} + +} // namespace + +void ProductProjectPanel::Initialize(const std::filesystem::path& repoRoot) { + m_assetsRootPath = (repoRoot / "project/Assets").lexically_normal(); + RefreshFolderTree(); + SyncCurrentFolderSelection(); + RefreshAssetList(); +} + +void ProductProjectPanel::SetTextMeasurer(const UIEditorTextMeasurer* textMeasurer) { + m_textMeasurer = textMeasurer; +} + +ProductProjectPanel::CursorKind ProductProjectPanel::GetCursorKind() const { + return (m_splitterHovered || m_splitterDragging) ? CursorKind::ResizeEW : CursorKind::Arrow; +} + +bool ProductProjectPanel::WantsHostPointerCapture() const { + return m_requestPointerCapture; +} + +bool ProductProjectPanel::WantsHostPointerRelease() const { + return m_requestPointerRelease; +} + +bool ProductProjectPanel::HasActivePointerCapture() const { + return m_splitterDragging; +} + +const ProductProjectPanel::FolderEntry* ProductProjectPanel::FindFolderEntry( + std::string_view itemId) const { + for (const FolderEntry& entry : m_folderEntries) { + if (entry.itemId == itemId) { + return &entry; + } + } + + return nullptr; +} + +const UIEditorPanelContentHostPanelState* ProductProjectPanel::FindMountedProjectPanel( + const UIEditorPanelContentHostFrame& contentHostFrame) const { + for (const UIEditorPanelContentHostPanelState& panelState : contentHostFrame.panelStates) { + if (panelState.panelId == kProjectPanelId && panelState.mounted) { + return &panelState; + } + } + + return nullptr; +} + +ProductProjectPanel::Layout ProductProjectPanel::BuildLayout(const UIRect& bounds) const { + Layout layout = {}; + const float dividerThickness = ResolveUIEditorDockHostMetrics().splitterMetrics.thickness; + layout.bounds = UIRect( + bounds.x, + bounds.y, + ClampNonNegative(bounds.width), + ClampNonNegative(bounds.height)); + + const float leftWidth = ClampNavigationWidth(m_navigationWidth, layout.bounds.width); + layout.leftPaneRect = UIRect( + layout.bounds.x, + layout.bounds.y, + leftWidth, + layout.bounds.height); + layout.dividerRect = UIRect( + layout.leftPaneRect.x + layout.leftPaneRect.width, + layout.bounds.y, + dividerThickness, + layout.bounds.height); + layout.rightPaneRect = UIRect( + layout.dividerRect.x + layout.dividerRect.width, + layout.bounds.y, + ClampNonNegative(layout.bounds.width - layout.leftPaneRect.width - layout.dividerRect.width), + layout.bounds.height); + + layout.treeRect = UIRect( + layout.leftPaneRect.x, + layout.leftPaneRect.y + kTreeTopPadding, + layout.leftPaneRect.width, + ClampNonNegative(layout.leftPaneRect.height - kTreeTopPadding)); + + layout.browserHeaderRect = UIRect( + layout.rightPaneRect.x, + layout.rightPaneRect.y, + layout.rightPaneRect.width, + (std::min)(kBrowserHeaderHeight, layout.rightPaneRect.height)); + layout.browserBodyRect = UIRect( + layout.rightPaneRect.x, + layout.browserHeaderRect.y + layout.browserHeaderRect.height, + layout.rightPaneRect.width, + ClampNonNegative(layout.rightPaneRect.height - layout.browserHeaderRect.height)); + layout.gridRect = UIRect( + layout.browserBodyRect.x + kGridInsetX, + layout.browserBodyRect.y + kGridInsetY, + ClampNonNegative(layout.browserBodyRect.width - kGridInsetX * 2.0f), + ClampNonNegative(layout.browserBodyRect.height - kGridInsetY * 2.0f)); + + const float breadcrumbRowHeight = kHeaderFontSize + kBreadcrumbItemPaddingY * 2.0f; + const float breadcrumbY = + layout.browserHeaderRect.y + std::floor((layout.browserHeaderRect.height - breadcrumbRowHeight) * 0.5f); + const float headerRight = + layout.browserHeaderRect.x + layout.browserHeaderRect.width - kHeaderHorizontalPadding; + float nextItemX = layout.browserHeaderRect.x + kHeaderHorizontalPadding; + std::string cumulativeFolderId = {}; + const std::vector segments = BuildBreadcrumbSegments(m_currentFolderId); + for (std::size_t index = 0u; index < segments.size(); ++index) { + if (index > 0u) { + const float separatorWidth = + MeasureTextWidth(m_textMeasurer, ">", kHeaderFontSize); + if (nextItemX < headerRight && separatorWidth > 0.0f) { + layout.breadcrumbItems.push_back({ + ">", + {}, + UIRect( + nextItemX, + breadcrumbY, + ClampNonNegative((std::min)(separatorWidth, headerRight - nextItemX)), + breadcrumbRowHeight), + true, + false, + false + }); + } + nextItemX += separatorWidth + kBreadcrumbSpacing; + } + + if (index == 0u) { + cumulativeFolderId = segments[index]; + } else { + cumulativeFolderId += "/"; + cumulativeFolderId += segments[index]; + } + + const float labelWidth = + MeasureTextWidth(m_textMeasurer, segments[index], kHeaderFontSize); + const float itemWidth = labelWidth + kBreadcrumbItemPaddingX * 2.0f; + const float availableWidth = headerRight - nextItemX; + if (availableWidth <= 0.0f) { + break; + } + + layout.breadcrumbItems.push_back({ + segments[index], + cumulativeFolderId, + UIRect( + nextItemX, + breadcrumbY, + ClampNonNegative((std::min)(itemWidth, availableWidth)), + breadcrumbRowHeight), + false, + index + 1u != segments.size(), + index + 1u == segments.size() + }); + nextItemX += itemWidth + kBreadcrumbSpacing; + } + + const float effectiveTileWidth = kGridTileWidth + kGridTileGapX; + int columnCount = effectiveTileWidth > 0.0f + ? static_cast((layout.gridRect.width + kGridTileGapX) / effectiveTileWidth) + : 1; + if (columnCount < 1) { + columnCount = 1; + } + + layout.assetTiles.reserve(m_assetEntries.size()); + for (std::size_t index = 0; index < m_assetEntries.size(); ++index) { + const int column = static_cast(index % static_cast(columnCount)); + const int row = static_cast(index / static_cast(columnCount)); + const float tileX = layout.gridRect.x + static_cast(column) * (kGridTileWidth + kGridTileGapX); + const float tileY = layout.gridRect.y + static_cast(row) * (kGridTileHeight + kGridTileGapY); + + AssetTileLayout tile = {}; + tile.itemIndex = index; + tile.tileRect = UIRect(tileX, tileY, kGridTileWidth, kGridTileHeight); + tile.previewRect = UIRect( + tile.tileRect.x + (tile.tileRect.width - kGridPreviewWidth) * 0.5f, + tile.tileRect.y + 6.0f, + kGridPreviewWidth, + kGridPreviewHeight); + tile.labelRect = UIRect( + tile.tileRect.x + 4.0f, + tile.previewRect.y + tile.previewRect.height + 8.0f, + tile.tileRect.width - 8.0f, + 18.0f); + layout.assetTiles.push_back(tile); + } + + return layout; +} + +std::size_t ProductProjectPanel::HitTestBreadcrumbItem(const UIPoint& point) const { + for (std::size_t index = 0u; index < m_layout.breadcrumbItems.size(); ++index) { + const BreadcrumbItemLayout& item = m_layout.breadcrumbItems[index]; + if (!item.separator && ContainsPoint(item.rect, point)) { + return index; + } + } + + return kInvalidLayoutIndex; +} + +std::size_t ProductProjectPanel::HitTestAssetTile(const UIPoint& point) const { + for (const AssetTileLayout& tile : m_layout.assetTiles) { + if (ContainsPoint(tile.tileRect, point)) { + return tile.itemIndex; + } + } + + return kInvalidLayoutIndex; +} + +void ProductProjectPanel::RefreshFolderTree() { + m_folderEntries.clear(); + m_treeItems.clear(); + + if (m_assetsRootPath.empty() || !std::filesystem::exists(m_assetsRootPath)) { + return; + } + + const auto appendFolderRecursive = + [&](auto&& self, const std::filesystem::path& folderPath, std::uint32_t depth) -> void { + const std::string itemId = BuildRelativeItemId(folderPath, m_assetsRootPath); + + FolderEntry folderEntry = {}; + folderEntry.itemId = itemId; + folderEntry.absolutePath = folderPath; + m_folderEntries.push_back(std::move(folderEntry)); + + Widgets::UIEditorTreeViewItem item = {}; + item.itemId = itemId; + item.label = PathToUtf8String(folderPath.filename()); + item.depth = depth; + item.forceLeaf = !HasChildDirectories(folderPath); + m_treeItems.push_back(std::move(item)); + + const std::vector childFolders = + CollectSortedChildDirectories(folderPath); + for (const std::filesystem::path& childPath : childFolders) { + self(self, childPath, depth + 1u); + } + }; + + appendFolderRecursive(appendFolderRecursive, m_assetsRootPath, 0u); +} + +void ProductProjectPanel::EnsureValidCurrentFolder() { + if (m_currentFolderId.empty()) { + m_currentFolderId = std::string(kAssetsRootId); + } + + if (FindFolderEntry(m_currentFolderId) == nullptr && !m_treeItems.empty()) { + m_currentFolderId = m_treeItems.front().itemId; + } +} + +void ProductProjectPanel::ExpandFolderAncestors(std::string_view itemId) { + const FolderEntry* folderEntry = FindFolderEntry(itemId); + if (folderEntry == nullptr) { + return; + } + + std::filesystem::path path = folderEntry->absolutePath; + while (true) { + m_folderExpansion.Expand(BuildRelativeItemId(path, m_assetsRootPath)); + if (path == m_assetsRootPath) { + break; + } + path = path.parent_path(); + } +} + +void ProductProjectPanel::SyncCurrentFolderSelection() { + EnsureValidCurrentFolder(); + ExpandFolderAncestors(m_currentFolderId); + m_folderSelection.SetSelection(m_currentFolderId); +} + +void ProductProjectPanel::NavigateToFolder(std::string_view itemId) { + if (itemId.empty() || FindFolderEntry(itemId) == nullptr || itemId == m_currentFolderId) { + return; + } + + m_currentFolderId = std::string(itemId); + SyncCurrentFolderSelection(); + m_assetSelection.ClearSelection(); + m_hoveredAssetItemId.clear(); + m_lastPrimaryClickedAssetId.clear(); + RefreshAssetList(); +} + +void ProductProjectPanel::RefreshAssetList() { + EnsureValidCurrentFolder(); + + m_assetEntries.clear(); + const FolderEntry* currentFolder = FindFolderEntry(m_currentFolderId); + if (currentFolder == nullptr) { + return; + } + + std::vector entries = {}; + std::error_code errorCode = {}; + const std::filesystem::directory_iterator end = {}; + for (std::filesystem::directory_iterator iterator(currentFolder->absolutePath, errorCode); + !errorCode && iterator != end; + iterator.increment(errorCode)) { + if (!iterator->exists(errorCode) || IsMetaFile(iterator->path())) { + continue; + } + if (!iterator->is_directory(errorCode) && !iterator->is_regular_file(errorCode)) { + continue; + } + + entries.push_back(*iterator); + } + + std::sort( + entries.begin(), + entries.end(), + [](const std::filesystem::directory_entry& lhs, const std::filesystem::directory_entry& rhs) { + const bool lhsDirectory = lhs.is_directory(); + const bool rhsDirectory = rhs.is_directory(); + if (lhsDirectory != rhsDirectory) { + return lhsDirectory && !rhsDirectory; + } + + return ToLowerCopy(PathToUtf8String(lhs.path().filename())) < + ToLowerCopy(PathToUtf8String(rhs.path().filename())); + }); + + for (const std::filesystem::directory_entry& entry : entries) { + AssetEntry assetEntry = {}; + assetEntry.itemId = BuildRelativeItemId(entry.path(), m_assetsRootPath); + assetEntry.absolutePath = entry.path(); + assetEntry.displayName = BuildAssetDisplayName(entry.path(), entry.is_directory()); + assetEntry.directory = entry.is_directory(); + m_assetEntries.push_back(std::move(assetEntry)); + } +} + +void ProductProjectPanel::ResetTransientFrames() { + m_treeFrame = {}; + m_layout = {}; + m_hoveredAssetItemId.clear(); + m_hoveredBreadcrumbIndex = kInvalidLayoutIndex; + m_pressedBreadcrumbIndex = kInvalidLayoutIndex; + m_splitterHovered = false; + m_splitterDragging = false; +} + +void ProductProjectPanel::Update( + const UIEditorPanelContentHostFrame& contentHostFrame, + const std::vector& inputEvents, + bool allowInteraction, + bool panelActive) { + m_requestPointerCapture = false; + m_requestPointerRelease = false; + + const UIEditorPanelContentHostPanelState* panelState = + FindMountedProjectPanel(contentHostFrame); + if (panelState == nullptr) { + if (m_splitterDragging) { + m_requestPointerRelease = true; + } + m_visible = false; + ResetTransientFrames(); + return; + } + + if (m_treeItems.empty()) { + RefreshFolderTree(); + SyncCurrentFolderSelection(); + RefreshAssetList(); + } + + m_visible = true; + const std::vector filteredEvents = + FilterProjectPanelInputEvents( + panelState->bounds, + inputEvents, + allowInteraction, + panelActive, + m_splitterDragging); + + m_navigationWidth = ClampNavigationWidth(m_navigationWidth, panelState->bounds.width); + m_layout = BuildLayout(panelState->bounds); + const UIEditorTreeViewMetrics treeMetrics = BuildTreeMetrics(); + const std::vector treeEvents = + FilterTreeInputEvents(filteredEvents, m_splitterDragging); + m_treeFrame = UpdateUIEditorTreeViewInteraction( + m_treeInteractionState, + m_folderSelection, + m_folderExpansion, + m_layout.treeRect, + m_treeItems, + treeEvents, + treeMetrics); + + if (m_treeFrame.result.selectionChanged && + !m_treeFrame.result.selectedItemId.empty() && + m_treeFrame.result.selectedItemId != m_currentFolderId) { + NavigateToFolder(m_treeFrame.result.selectedItemId); + m_layout = BuildLayout(panelState->bounds); + } + + for (const UIInputEvent& event : filteredEvents) { + switch (event.type) { + case UIInputEventType::FocusLost: + m_hoveredAssetItemId.clear(); + m_hoveredBreadcrumbIndex = kInvalidLayoutIndex; + m_pressedBreadcrumbIndex = kInvalidLayoutIndex; + m_splitterHovered = false; + if (m_splitterDragging) { + m_splitterDragging = false; + m_requestPointerRelease = true; + } + break; + + case UIInputEventType::PointerMove: { + if (m_splitterDragging) { + m_navigationWidth = + ClampNavigationWidth(event.position.x - panelState->bounds.x, panelState->bounds.width); + m_layout = BuildLayout(panelState->bounds); + } + + m_splitterHovered = + m_splitterDragging || ContainsPoint(m_layout.dividerRect, event.position); + m_hoveredBreadcrumbIndex = HitTestBreadcrumbItem(event.position); + const std::size_t hoveredAssetIndex = HitTestAssetTile(event.position); + m_hoveredAssetItemId = + hoveredAssetIndex < m_assetEntries.size() + ? m_assetEntries[hoveredAssetIndex].itemId + : std::string(); + break; + } + + case UIInputEventType::PointerLeave: + if (!m_splitterDragging) { + m_splitterHovered = false; + } + m_hoveredBreadcrumbIndex = kInvalidLayoutIndex; + m_hoveredAssetItemId.clear(); + break; + + case UIInputEventType::PointerButtonDown: + if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) { + break; + } + + if (ContainsPoint(m_layout.dividerRect, event.position)) { + m_splitterDragging = true; + m_splitterHovered = true; + m_pressedBreadcrumbIndex = kInvalidLayoutIndex; + m_requestPointerCapture = true; + break; + } + + m_pressedBreadcrumbIndex = HitTestBreadcrumbItem(event.position); + + if (!ContainsPoint(m_layout.gridRect, event.position)) { + break; + } + + { + const std::size_t hitIndex = HitTestAssetTile(event.position); + if (hitIndex >= m_assetEntries.size()) { + m_assetSelection.ClearSelection(); + break; + } + + const AssetEntry& assetEntry = m_assetEntries[hitIndex]; + const bool alreadySelected = m_assetSelection.IsSelected(assetEntry.itemId); + m_assetSelection.SetSelection(assetEntry.itemId); + + if (!assetEntry.directory) { + m_lastPrimaryClickedAssetId = assetEntry.itemId; + m_lastPrimaryClickTimeMs = GetTickCount64(); + break; + } + + const std::uint64_t nowMs = GetTickCount64(); + const std::uint64_t doubleClickThresholdMs = + static_cast(GetDoubleClickTime()); + const bool doubleClicked = + alreadySelected && + m_lastPrimaryClickedAssetId == assetEntry.itemId && + nowMs >= m_lastPrimaryClickTimeMs && + nowMs - m_lastPrimaryClickTimeMs <= doubleClickThresholdMs; + + m_lastPrimaryClickedAssetId = assetEntry.itemId; + m_lastPrimaryClickTimeMs = nowMs; + if (!doubleClicked) { + break; + } + + NavigateToFolder(assetEntry.itemId); + m_layout = BuildLayout(panelState->bounds); + m_hoveredAssetItemId.clear(); + } + break; + + case UIInputEventType::PointerButtonUp: + if (event.pointerButton != ::XCEngine::UI::UIPointerButton::Left) { + break; + } + + if (m_splitterDragging) { + m_splitterDragging = false; + m_splitterHovered = ContainsPoint(m_layout.dividerRect, event.position); + m_requestPointerRelease = true; + break; + } + + { + const std::size_t releasedBreadcrumbIndex = + HitTestBreadcrumbItem(event.position); + if (m_pressedBreadcrumbIndex != kInvalidLayoutIndex && + m_pressedBreadcrumbIndex == releasedBreadcrumbIndex && + releasedBreadcrumbIndex < m_layout.breadcrumbItems.size()) { + const BreadcrumbItemLayout& item = + m_layout.breadcrumbItems[releasedBreadcrumbIndex]; + if (item.clickable) { + NavigateToFolder(item.targetFolderId); + m_layout = BuildLayout(panelState->bounds); + } + } + m_pressedBreadcrumbIndex = kInvalidLayoutIndex; + } + break; + + default: + break; + } + } +} + +void ProductProjectPanel::Append(UIDrawList& drawList) const { + if (!m_visible || m_layout.bounds.width <= 0.0f || m_layout.bounds.height <= 0.0f) { + return; + } + + drawList.AddFilledRect(m_layout.bounds, kSurfaceColor); + drawList.AddFilledRect(m_layout.leftPaneRect, kPaneColor); + drawList.AddFilledRect(m_layout.rightPaneRect, kPaneColor); + drawList.AddFilledRect( + m_layout.dividerRect, + ResolveUIEditorDockHostPalette().splitterColor); + + drawList.AddFilledRect(m_layout.browserHeaderRect, kHeaderColor); + drawList.AddFilledRect( + UIRect( + m_layout.browserHeaderRect.x, + m_layout.browserHeaderRect.y + m_layout.browserHeaderRect.height - kHeaderBottomBorderThickness, + m_layout.browserHeaderRect.width, + kHeaderBottomBorderThickness), + ResolveUIEditorDockHostPalette().splitterColor); + + const UIEditorTreeViewPalette treePalette = BuildTreePalette(); + const UIEditorTreeViewMetrics treeMetrics = BuildTreeMetrics(); + AppendUIEditorTreeViewBackground( + drawList, + m_treeFrame.layout, + m_treeItems, + m_folderSelection, + m_treeInteractionState.treeViewState, + treePalette, + treeMetrics); + AppendUIEditorTreeViewForeground( + drawList, + m_treeFrame.layout, + m_treeItems, + treePalette, + treeMetrics); + + drawList.PushClipRect(m_layout.browserHeaderRect); + for (std::size_t index = 0u; index < m_layout.breadcrumbItems.size(); ++index) { + const BreadcrumbItemLayout& item = m_layout.breadcrumbItems[index]; + const UIColor textColor = + item.separator + ? kTextMuted + : (index == m_hoveredBreadcrumbIndex && item.clickable + ? kTextStrong + : (item.current ? kTextPrimary : kTextMuted)); + const float textWidth = MeasureTextWidth(m_textMeasurer, item.label, kHeaderFontSize); + const float textX = item.separator + ? item.rect.x + : item.rect.x + (item.rect.width - textWidth) * 0.5f; + drawList.AddText( + UIPoint(textX, ResolveTextTop(item.rect.y, item.rect.height, kHeaderFontSize)), + item.label, + textColor, + kHeaderFontSize); + } + drawList.PopClipRect(); + + for (const AssetTileLayout& tile : m_layout.assetTiles) { + if (tile.itemIndex >= m_assetEntries.size()) { + continue; + } + + const AssetEntry& assetEntry = m_assetEntries[tile.itemIndex]; + const bool selected = m_assetSelection.IsSelected(assetEntry.itemId); + const bool hovered = m_hoveredAssetItemId == assetEntry.itemId; + + if (selected || hovered) { + drawList.AddFilledRect( + tile.tileRect, + selected ? kTileSelectedColor : kTileHoverColor); + } + + AppendTilePreview(drawList, tile.previewRect, assetEntry.directory); + + drawList.PushClipRect(tile.labelRect); + const float textWidth = + MeasureTextWidth(m_textMeasurer, assetEntry.displayName, kTileLabelFontSize); + drawList.AddText( + UIPoint( + tile.labelRect.x + (tile.labelRect.width - textWidth) * 0.5f, + ResolveTextTop(tile.labelRect.y, tile.labelRect.height, kTileLabelFontSize)), + assetEntry.displayName, + kTextPrimary, + kTileLabelFontSize); + drawList.PopClipRect(); + } + + if (m_assetEntries.empty()) { + const UIRect messageRect( + m_layout.gridRect.x, + m_layout.gridRect.y, + m_layout.gridRect.width, + 18.0f); + drawList.AddText( + UIPoint(messageRect.x, ResolveTextTop(messageRect.y, messageRect.height, kHeaderFontSize)), + "Current folder is empty.", + kTextMuted, + kHeaderFontSize); + } +} + +} // namespace XCEngine::UI::Editor::App diff --git a/new_editor/app/Panels/ProductProjectPanel.h b/new_editor/app/Panels/ProductProjectPanel.h new file mode 100644 index 00000000..944e3024 --- /dev/null +++ b/new_editor/app/Panels/ProductProjectPanel.h @@ -0,0 +1,121 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace XCEngine::UI::Editor::App { + +class ProductProjectPanel { +public: + enum class CursorKind : std::uint8_t { + Arrow = 0, + ResizeEW + }; + + void Initialize(const std::filesystem::path& repoRoot); + void SetTextMeasurer(const ::XCEngine::UI::Editor::UIEditorTextMeasurer* textMeasurer); + void Update( + const UIEditorPanelContentHostFrame& contentHostFrame, + const std::vector<::XCEngine::UI::UIInputEvent>& inputEvents, + bool allowInteraction, + bool panelActive); + void Append(::XCEngine::UI::UIDrawList& drawList) const; + + CursorKind GetCursorKind() const; + bool WantsHostPointerCapture() const; + bool WantsHostPointerRelease() const; + bool HasActivePointerCapture() const; + +private: + struct FolderEntry { + std::string itemId = {}; + std::filesystem::path absolutePath = {}; + }; + + struct AssetEntry { + std::string itemId = {}; + std::filesystem::path absolutePath = {}; + std::string displayName = {}; + bool directory = false; + }; + + struct BreadcrumbItemLayout { + std::string label = {}; + std::string targetFolderId = {}; + ::XCEngine::UI::UIRect rect = {}; + bool separator = false; + bool clickable = false; + bool current = false; + }; + + struct AssetTileLayout { + std::size_t itemIndex = static_cast(-1); + ::XCEngine::UI::UIRect tileRect = {}; + ::XCEngine::UI::UIRect previewRect = {}; + ::XCEngine::UI::UIRect labelRect = {}; + }; + + struct Layout { + ::XCEngine::UI::UIRect bounds = {}; + ::XCEngine::UI::UIRect leftPaneRect = {}; + ::XCEngine::UI::UIRect treeRect = {}; + ::XCEngine::UI::UIRect dividerRect = {}; + ::XCEngine::UI::UIRect rightPaneRect = {}; + ::XCEngine::UI::UIRect browserHeaderRect = {}; + ::XCEngine::UI::UIRect browserBodyRect = {}; + ::XCEngine::UI::UIRect gridRect = {}; + std::vector breadcrumbItems = {}; + std::vector assetTiles = {}; + }; + + const FolderEntry* FindFolderEntry(std::string_view itemId) const; + const UIEditorPanelContentHostPanelState* FindMountedProjectPanel( + const UIEditorPanelContentHostFrame& contentHostFrame) const; + Layout BuildLayout(const ::XCEngine::UI::UIRect& bounds) const; + std::size_t HitTestBreadcrumbItem(const ::XCEngine::UI::UIPoint& point) const; + std::size_t HitTestAssetTile(const ::XCEngine::UI::UIPoint& point) const; + void RefreshFolderTree(); + void RefreshAssetList(); + void EnsureValidCurrentFolder(); + void ExpandFolderAncestors(std::string_view itemId); + void SyncCurrentFolderSelection(); + void NavigateToFolder(std::string_view itemId); + void ResetTransientFrames(); + + std::filesystem::path m_assetsRootPath = {}; + std::vector m_folderEntries = {}; + std::vector m_treeItems = {}; + std::vector m_assetEntries = {}; + const ::XCEngine::UI::Editor::UIEditorTextMeasurer* m_textMeasurer = nullptr; + ::XCEngine::UI::Widgets::UISelectionModel m_folderSelection = {}; + ::XCEngine::UI::Widgets::UIExpansionModel m_folderExpansion = {}; + ::XCEngine::UI::Widgets::UISelectionModel m_assetSelection = {}; + UIEditorTreeViewInteractionState m_treeInteractionState = {}; + UIEditorTreeViewInteractionFrame m_treeFrame = {}; + Layout m_layout = {}; + std::string m_currentFolderId = {}; + std::string m_hoveredAssetItemId = {}; + std::string m_lastPrimaryClickedAssetId = {}; + float m_navigationWidth = 248.0f; + std::uint64_t m_lastPrimaryClickTimeMs = 0u; + std::size_t m_hoveredBreadcrumbIndex = static_cast(-1); + std::size_t m_pressedBreadcrumbIndex = static_cast(-1); + bool m_visible = false; + bool m_splitterHovered = false; + bool m_splitterDragging = false; + bool m_requestPointerCapture = false; + bool m_requestPointerRelease = false; +}; + +} // namespace XCEngine::UI::Editor::App diff --git a/new_editor/app/Shell/ProductShellAsset.cpp b/new_editor/app/Shell/ProductShellAsset.cpp index 201a3266..5c7f9386 100644 --- a/new_editor/app/Shell/ProductShellAsset.cpp +++ b/new_editor/app/Shell/ProductShellAsset.cpp @@ -25,7 +25,7 @@ UIEditorPanelRegistry BuildPanelRegistry() { { "game", "Game", UIEditorPanelPresentationKind::ViewportShell, false, false, false }, { "inspector", "Inspector", UIEditorPanelPresentationKind::Placeholder, true, false, false }, { "console", "Console", UIEditorPanelPresentationKind::Placeholder, true, false, false }, - { "project", "Project", UIEditorPanelPresentationKind::Placeholder, true, false, false } + { "project", "Project", UIEditorPanelPresentationKind::HostedContent, false, false, false } }; return registry; } @@ -81,9 +81,9 @@ UIEditorWorkspaceModel BuildWorkspace() { "project-panel", "project", "Project", - true) + false) }, - 0u)); + 1u)); workspace.activePanelId = "scene"; return workspace; } @@ -434,6 +434,14 @@ UIEditorWorkspacePanelPresentationModel BuildViewportPresentation( return presentation; } +UIEditorWorkspacePanelPresentationModel BuildHostedContentPresentation( + std::string panelId) { + UIEditorWorkspacePanelPresentationModel presentation = {}; + presentation.panelId = std::move(panelId); + presentation.kind = UIEditorPanelPresentationKind::HostedContent; + return presentation; +} + UIEditorShellInteractionDefinition BuildBaseShellDefinition() { UIEditorShellInteractionDefinition definition = {}; definition.menuModel = BuildMenuModel(); @@ -449,7 +457,7 @@ UIEditorShellInteractionDefinition BuildBaseShellDefinition() { BuildViewportPresentation("game", "Game", "Display 1", "Game preview host ready"), BuildPlaceholderPresentation("inspector"), BuildPlaceholderPresentation("console"), - BuildPlaceholderPresentation("project") + BuildHostedContentPresentation("project") }; return definition; } diff --git a/new_editor/include/XCEditor/Collections/UIEditorTabStrip.h b/new_editor/include/XCEditor/Collections/UIEditorTabStrip.h index 3ef65de5..5bf51347 100644 --- a/new_editor/include/XCEditor/Collections/UIEditorTabStrip.h +++ b/new_editor/include/XCEditor/Collections/UIEditorTabStrip.h @@ -65,9 +65,9 @@ struct UIEditorTabStripPalette { ::XCEngine::UI::UIColor contentBackgroundColor = ::XCEngine::UI::UIColor(0.18f, 0.18f, 0.18f, 1.0f); ::XCEngine::UI::UIColor stripBorderColor = - ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor(0.13f, 0.13f, 0.13f, 1.0f); ::XCEngine::UI::UIColor headerContentSeparatorColor = - ::XCEngine::UI::UIColor(0.27f, 0.27f, 0.27f, 1.0f); + ::XCEngine::UI::UIColor(0.13f, 0.13f, 0.13f, 1.0f); ::XCEngine::UI::UIColor focusedBorderColor = ::XCEngine::UI::UIColor(0.36f, 0.36f, 0.36f, 1.0f); ::XCEngine::UI::UIColor tabColor = @@ -77,11 +77,11 @@ struct UIEditorTabStripPalette { ::XCEngine::UI::UIColor tabSelectedColor = ::XCEngine::UI::UIColor(0.18f, 0.18f, 0.18f, 1.0f); ::XCEngine::UI::UIColor tabBorderColor = - ::XCEngine::UI::UIColor(0.24f, 0.24f, 0.24f, 1.0f); + ::XCEngine::UI::UIColor(0.13f, 0.13f, 0.13f, 1.0f); ::XCEngine::UI::UIColor tabHoveredBorderColor = - ::XCEngine::UI::UIColor(0.31f, 0.31f, 0.31f, 1.0f); + ::XCEngine::UI::UIColor(0.13f, 0.13f, 0.13f, 1.0f); ::XCEngine::UI::UIColor tabSelectedBorderColor = - ::XCEngine::UI::UIColor(0.34f, 0.34f, 0.34f, 1.0f); + ::XCEngine::UI::UIColor(0.13f, 0.13f, 0.13f, 1.0f); ::XCEngine::UI::UIColor textPrimary = ::XCEngine::UI::UIColor(0.86f, 0.86f, 0.86f, 1.0f); ::XCEngine::UI::UIColor textSecondary = diff --git a/new_editor/include/XCEditor/Shell/UIEditorDockHost.h b/new_editor/include/XCEditor/Shell/UIEditorDockHost.h index 11884b71..e3ce5cd6 100644 --- a/new_editor/include/XCEditor/Shell/UIEditorDockHost.h +++ b/new_editor/include/XCEditor/Shell/UIEditorDockHost.h @@ -60,7 +60,7 @@ struct UIEditorDockHostState { struct UIEditorDockHostMetrics { ::XCEngine::UI::Layout::UISplitterMetrics splitterMetrics = - ::XCEngine::UI::Layout::UISplitterMetrics{ 1.0f, 10.0f }; + ::XCEngine::UI::Layout::UISplitterMetrics{ 2.0f, 10.0f }; UIEditorTabStripMetrics tabStripMetrics = {}; UIEditorPanelFrameMetrics panelFrameMetrics = {}; ::XCEngine::UI::UISize minimumStandalonePanelBodySize = @@ -75,11 +75,11 @@ struct UIEditorDockHostPalette { UIEditorTabStripPalette tabStripPalette = {}; UIEditorPanelFramePalette panelFramePalette = {}; ::XCEngine::UI::UIColor splitterColor = - ::XCEngine::UI::UIColor(0.22f, 0.22f, 0.22f, 1.0f); + ::XCEngine::UI::UIColor(0.13f, 0.13f, 0.13f, 1.0f); ::XCEngine::UI::UIColor splitterHoveredColor = - ::XCEngine::UI::UIColor(0.29f, 0.29f, 0.29f, 1.0f); + ::XCEngine::UI::UIColor(0.13f, 0.13f, 0.13f, 1.0f); ::XCEngine::UI::UIColor splitterActiveColor = - ::XCEngine::UI::UIColor(0.35f, 0.35f, 0.35f, 1.0f); + ::XCEngine::UI::UIColor(0.13f, 0.13f, 0.13f, 1.0f); ::XCEngine::UI::UIColor placeholderTitleColor = ::XCEngine::UI::UIColor(0.93f, 0.94f, 0.96f, 1.0f); ::XCEngine::UI::UIColor placeholderTextColor = diff --git a/new_editor/src/Collections/UIEditorTabStrip.cpp b/new_editor/src/Collections/UIEditorTabStrip.cpp index e37c4d88..f352ab10 100644 --- a/new_editor/src/Collections/UIEditorTabStrip.cpp +++ b/new_editor/src/Collections/UIEditorTabStrip.cpp @@ -88,20 +88,6 @@ float ResolveTabTextLeft( return availableLeft + (std::max)(0.0f, (availableWidth - labelWidth) * 0.5f); } -UIColor ResolveStripBorderColor( - const UIEditorTabStripState& state, - const UIEditorTabStripPalette& palette) { - (void)state; - return palette.stripBorderColor; -} - -float ResolveStripBorderThickness( - const UIEditorTabStripState& state, - const UIEditorTabStripMetrics& metrics) { - (void)state; - return metrics.baseBorderThickness; -} - UIColor ResolveTabFillColor( bool selected, bool hovered, @@ -359,11 +345,6 @@ void AppendUIEditorTabStripBackground( if (layout.headerRect.height > 0.0f) { drawList.AddFilledRect(layout.headerRect, palette.headerBackgroundColor, stripRounding); } - drawList.AddRectOutline( - layout.bounds, - ResolveStripBorderColor(state, palette), - ResolveStripBorderThickness(state, metrics), - stripRounding); for (std::size_t index = 0; index < layout.tabHeaderRects.size(); ++index) { const bool selected = layout.selectedIndex == index; diff --git a/new_editor/src/Collections/UIEditorTreeView.cpp b/new_editor/src/Collections/UIEditorTreeView.cpp index 34deda3d..601e35eb 100644 --- a/new_editor/src/Collections/UIEditorTreeView.cpp +++ b/new_editor/src/Collections/UIEditorTreeView.cpp @@ -3,6 +3,7 @@ #include #include +#include #include namespace XCEngine::UI::Editor::Widgets { @@ -19,16 +20,42 @@ std::vector BuildItemOffsets(std::size_t count) { return offsets; } +constexpr float kTreeFontSize = 12.0f; float ResolveTreeViewRowHeight( const UIEditorTreeViewItem& item, const UIEditorTreeViewMetrics& metrics) { return item.desiredHeight > 0.0f ? item.desiredHeight : metrics.rowHeight; } -::XCEngine::UI::UIPoint ResolveDisclosureGlyphPosition( +float ResolveTextTop(const ::XCEngine::UI::UIRect& rect, float fontSize) { + const float lineHeight = fontSize * 1.6f; + return rect.y + std::floor((rect.height - lineHeight) * 0.5f); +} + +void AppendDisclosureArrow( + ::XCEngine::UI::UIDrawList& drawList, const ::XCEngine::UI::UIRect& rect, - float insetY) { - return ::XCEngine::UI::UIPoint(rect.x + 2.0f, rect.y + insetY - 1.0f); + bool expanded, + const ::XCEngine::UI::UIColor& color) { + constexpr float kOpticalCenterYOffset = -0.5f; + const float centerX = std::floor(rect.x + rect.width * 0.5f) + 0.5f; + const float centerY = + std::floor(rect.y + rect.height * 0.5f + kOpticalCenterYOffset) + 0.5f; + const float halfExtent = (std::max)(2.5f, std::floor((std::min)(rect.width, rect.height) * 0.24f)); + const float triangleHeight = halfExtent * 1.55f; + + ::XCEngine::UI::UIPoint points[3] = {}; + if (expanded) { + points[0] = ::XCEngine::UI::UIPoint(centerX - halfExtent, centerY - triangleHeight * 0.5f); + points[1] = ::XCEngine::UI::UIPoint(centerX + halfExtent, centerY - triangleHeight * 0.5f); + points[2] = ::XCEngine::UI::UIPoint(centerX, centerY + triangleHeight * 0.5f); + } else { + points[0] = ::XCEngine::UI::UIPoint(centerX - triangleHeight * 0.5f, centerY - halfExtent); + points[1] = ::XCEngine::UI::UIPoint(centerX - triangleHeight * 0.5f, centerY + halfExtent); + points[2] = ::XCEngine::UI::UIPoint(centerX + triangleHeight * 0.5f, centerY); + } + + drawList.AddFilledTriangle(points[0], points[1], points[2], color); } } // namespace @@ -267,23 +294,21 @@ void AppendUIEditorTreeViewForeground( for (std::size_t visibleOffset = 0u; visibleOffset < layout.rowRects.size(); ++visibleOffset) { const UIEditorTreeViewItem& item = items[layout.visibleItemIndices[visibleOffset]]; if (layout.itemHasChildren[visibleOffset]) { - drawList.AddText( - ResolveDisclosureGlyphPosition( - layout.disclosureRects[visibleOffset], - metrics.labelInsetY), - layout.itemExpanded[visibleOffset] ? "v" : ">", - palette.disclosureColor, - 12.0f); + AppendDisclosureArrow( + drawList, + layout.disclosureRects[visibleOffset], + layout.itemExpanded[visibleOffset], + palette.disclosureColor); } drawList.PushClipRect(layout.labelRects[visibleOffset]); drawList.AddText( ::XCEngine::UI::UIPoint( layout.labelRects[visibleOffset].x, - layout.labelRects[visibleOffset].y + metrics.labelInsetY), + ResolveTextTop(layout.labelRects[visibleOffset], kTreeFontSize)), item.label, palette.textColor, - 12.0f); + kTreeFontSize); drawList.PopClipRect(); } drawList.PopClipRect();