#include "Actions/ActionRouting.h" #include "Actions/ConsoleActionRouter.h" #include "ConsolePanel.h" #include "Core/EditorConsoleSink.h" #include "Core/EditorEvents.h" #include "Core/EventBus.h" #include "Core/IEditorContext.h" #include "Platform/Win32Utf8.h" #include "UI/UI.h" #include #include #include #include #include #include #include #include #include #include namespace { using XCEngine::Debug::EditorConsoleRecord; using XCEngine::Debug::EditorConsoleSink; using XCEngine::Debug::LogEntry; using XCEngine::Debug::LogLevel; enum class ConsoleSeverityVisual { Log, Warning, Error }; struct ConsoleSeverityCounts { size_t logCount = 0; size_t warningCount = 0; size_t errorCount = 0; }; bool CanOpenSourceLocation(const LogEntry& entry); struct ConsoleRowData { uint64_t serial = 0; LogEntry entry = {}; std::string entryKey; size_t count = 1; }; struct ConsoleRowInteraction { bool clicked = false; bool rightClicked = false; bool doubleClicked = false; }; bool CanOpenSourceLocation(const LogEntry& entry); constexpr float kConsoleToolbarHeight = 26.0f; constexpr float kConsoleToolbarButtonHeight = 20.0f; constexpr float kConsoleToolbarRowPaddingY = 3.0f; constexpr float kConsoleCounterWidth = 36.0f; constexpr float kConsoleSearchWidth = 220.0f; constexpr float kConsoleRowHeight = 20.0f; constexpr float kConsoleDetailsHeaderHeight = 24.0f; constexpr float kConsoleToolbarItemSpacing = 4.0f; constexpr ImVec4 kConsoleToolbarBackgroundColor = ImVec4(0.23f, 0.23f, 0.23f, 1.0f); std::filesystem::path ResolveConsoleIconPath(const wchar_t* fileName) { const std::filesystem::path exeDir(XCEngine::Editor::Platform::Utf8ToWide(XCEngine::Editor::Platform::GetExecutableDirectoryUtf8())); return (exeDir / L".." / L".." / L"resources" / L"Icons" / fileName).lexically_normal(); } const std::string& ConsoleInfoRowIconPath() { static const std::string path = XCEngine::Editor::Platform::WideToUtf8(ResolveConsoleIconPath(L"console__info_icon.png").wstring()); return path; } const std::string& ConsoleWarnRowIconPath() { static const std::string path = XCEngine::Editor::Platform::WideToUtf8(ResolveConsoleIconPath(L"console_warn_icon.png").wstring()); return path; } const std::string& ConsoleErrorRowIconPath() { static const std::string path = XCEngine::Editor::Platform::WideToUtf8(ResolveConsoleIconPath(L"console_error_icon.png").wstring()); return path; } const std::string& ConsoleInfoButtonIconPath() { static const std::string path = XCEngine::Editor::Platform::WideToUtf8(ResolveConsoleIconPath(L"console_info_button_icon.png").wstring()); return path; } const std::string& ConsoleWarnButtonIconPath() { static const std::string path = XCEngine::Editor::Platform::WideToUtf8(ResolveConsoleIconPath(L"console_warn_button_icon.png").wstring()); return path; } const std::string& ConsoleErrorButtonIconPath() { static const std::string path = XCEngine::Editor::Platform::WideToUtf8(ResolveConsoleIconPath(L"console_error_button_icon.png").wstring()); return path; } const std::string& IconPathForSeverity(ConsoleSeverityVisual severity, bool toolbarButton) { switch (severity) { case ConsoleSeverityVisual::Warning: return toolbarButton ? ConsoleWarnButtonIconPath() : ConsoleWarnRowIconPath(); case ConsoleSeverityVisual::Error: return toolbarButton ? ConsoleErrorButtonIconPath() : ConsoleErrorRowIconPath(); case ConsoleSeverityVisual::Log: default: return toolbarButton ? ConsoleInfoButtonIconPath() : ConsoleInfoRowIconPath(); } } ConsoleSeverityVisual ResolveSeverity(LogLevel level) { switch (level) { case LogLevel::Warning: return ConsoleSeverityVisual::Warning; case LogLevel::Error: case LogLevel::Fatal: return ConsoleSeverityVisual::Error; case LogLevel::Verbose: case LogLevel::Debug: case LogLevel::Info: default: return ConsoleSeverityVisual::Log; } } ImVec4 SeverityColor(ConsoleSeverityVisual severity) { switch (severity) { case ConsoleSeverityVisual::Warning: return XCEngine::Editor::UI::ConsoleWarningColor(); case ConsoleSeverityVisual::Error: return XCEngine::Editor::UI::ConsoleErrorColor(); case ConsoleSeverityVisual::Log: default: return XCEngine::Editor::UI::ConsoleLogColor(); } } bool IsErrorLevel(LogLevel level) { return level == LogLevel::Error || level == LogLevel::Fatal; } std::string BuildEntryKey(const LogEntry& entry) { std::string key; key.reserve(128 + entry.message.Length() + entry.file.Length() + entry.function.Length()); key += std::to_string(static_cast(entry.level)); key.push_back('\x1f'); key += std::to_string(static_cast(entry.category)); key.push_back('\x1f'); key += entry.message.CStr(); key.push_back('\x1f'); key += entry.file.CStr(); key.push_back('\x1f'); key += std::to_string(entry.line); key.push_back('\x1f'); key += entry.function.CStr(); return key; } std::string BuildSearchHaystack(const LogEntry& entry) { std::string haystack = XCEngine::Editor::UI::BuildConsoleLogText(entry); haystack.push_back('\n'); haystack += entry.file.CStr(); haystack.push_back('\n'); haystack += entry.function.CStr(); return haystack; } bool MatchesSearch(const LogEntry& entry, const XCEngine::Editor::UI::SearchQuery& searchQuery) { return searchQuery.Matches(BuildSearchHaystack(entry)); } void CountSeverity(ConsoleSeverityCounts& counts, LogLevel level) { switch (ResolveSeverity(level)) { case ConsoleSeverityVisual::Warning: ++counts.warningCount; break; case ConsoleSeverityVisual::Error: ++counts.errorCount; break; case ConsoleSeverityVisual::Log: default: ++counts.logCount; break; } } uint64_t FindLatestSerial(const std::vector& records) { uint64_t latestSerial = 0; for (const EditorConsoleRecord& record : records) { latestSerial = (std::max)(latestSerial, record.serial); } return latestSerial; } std::vector BuildVisibleRows( const std::vector& records, const XCEngine::Editor::UI::ConsoleFilterState& filterState, const XCEngine::Editor::UI::SearchQuery& searchQuery, ConsoleSeverityCounts& counts) { std::vector rows; rows.reserve(records.size()); if (!filterState.Collapse()) { for (const EditorConsoleRecord& record : records) { CountSeverity(counts, record.entry.level); if (!filterState.Allows(record.entry.level) || !MatchesSearch(record.entry, searchQuery)) { continue; } rows.push_back(ConsoleRowData{ record.serial, record.entry, BuildEntryKey(record.entry), 1u }); } return rows; } std::unordered_map rowIndicesByKey; rowIndicesByKey.reserve(records.size()); for (const EditorConsoleRecord& record : records) { CountSeverity(counts, record.entry.level); if (!filterState.Allows(record.entry.level) || !MatchesSearch(record.entry, searchQuery)) { continue; } const std::string entryKey = BuildEntryKey(record.entry); const auto it = rowIndicesByKey.find(entryKey); if (it == rowIndicesByKey.end()) { rowIndicesByKey.emplace(entryKey, rows.size()); rows.push_back(ConsoleRowData{ record.serial, record.entry, entryKey, 1u }); continue; } ConsoleRowData& row = rows[it->second]; row.serial = record.serial; row.entry = record.entry; ++row.count; } return rows; } const ConsoleRowData* ResolveSelectedRow( const std::vector& rows, uint64_t& selectedSerial, const std::string& selectedEntryKey) { if (selectedSerial != 0) { for (const ConsoleRowData& row : rows) { if (row.serial == selectedSerial) { return &row; } } } if (!selectedEntryKey.empty()) { for (const ConsoleRowData& row : rows) { if (row.entryKey == selectedEntryKey) { selectedSerial = row.serial; return &row; } } } selectedSerial = 0; return nullptr; } bool SelectRelativeRow(const std::vector& rows, uint64_t& selectedSerial, int delta) { if (rows.empty() || delta == 0) { return false; } int currentIndex = -1; for (int i = 0; i < static_cast(rows.size()); ++i) { if (rows[i].serial == selectedSerial) { currentIndex = i; break; } } int nextIndex = 0; if (currentIndex >= 0) { nextIndex = std::clamp(currentIndex + delta, 0, static_cast(rows.size()) - 1); } else if (delta > 0) { nextIndex = 0; } else { nextIndex = static_cast(rows.size()) - 1; } if (rows[nextIndex].serial == selectedSerial) { return false; } selectedSerial = rows[nextIndex].serial; return true; } void DrawSeverityIcon( ImDrawList* drawList, const ImVec2& center, float radius, ConsoleSeverityVisual severity, bool toolbarButton = false) { const std::string& iconPath = IconPathForSeverity(severity, toolbarButton); if (!iconPath.empty() && XCEngine::Editor::UI::DrawTextureAssetPreview( drawList, ImVec2(center.x - radius, center.y - radius), ImVec2(center.x + radius, center.y + radius), iconPath)) { return; } const ImU32 color = ImGui::GetColorU32(SeverityColor(severity)); switch (severity) { case ConsoleSeverityVisual::Warning: drawList->AddTriangleFilled( ImVec2(center.x, center.y - radius), ImVec2(center.x - radius * 0.90f, center.y + radius * 0.78f), ImVec2(center.x + radius * 0.90f, center.y + radius * 0.78f), color); break; case ConsoleSeverityVisual::Error: { ImVec2 points[8]; for (int i = 0; i < 8; ++i) { const float angle = (45.0f * static_cast(i) + 22.5f) * 3.14159265f / 180.0f; points[i] = ImVec2(center.x + std::cos(angle) * radius, center.y + std::sin(angle) * radius); } drawList->AddConvexPolyFilled(points, 8, color); break; } case ConsoleSeverityVisual::Log: default: drawList->AddCircleFilled(center, radius, color, 16); break; } } bool DrawToolbarDropdownButton( const char* id, const char* label, float width, const std::function& drawPopup, bool active = false) { const ImVec2 size(width, kConsoleToolbarButtonHeight); ImGui::InvisibleButton(id, size); const bool hovered = ImGui::IsItemHovered(); const bool held = ImGui::IsItemActive(); const bool pressed = ImGui::IsItemClicked(ImGuiMouseButton_Left); const ImVec2 min = ImGui::GetItemRectMin(); const ImVec2 max = ImGui::GetItemRectMax(); ImDrawList* drawList = ImGui::GetWindowDrawList(); drawList->AddRectFilled( min, max, ImGui::GetColorU32( held ? XCEngine::Editor::UI::ToolbarButtonActiveColor() : hovered ? XCEngine::Editor::UI::ToolbarButtonHoveredColor(active) : XCEngine::Editor::UI::ToolbarButtonColor(active)), 2.0f); const ImVec2 textSize = ImGui::CalcTextSize(label); drawList->AddText( ImVec2(min.x + 8.0f, min.y + (size.y - textSize.y) * 0.5f), ImGui::GetColorU32(ImGuiCol_Text), label); const float arrowCenterX = max.x - 9.0f; const float arrowCenterY = min.y + size.y * 0.5f + 1.0f; drawList->AddTriangleFilled( ImVec2(arrowCenterX - 3.0f, arrowCenterY - 2.0f), ImVec2(arrowCenterX + 3.0f, arrowCenterY - 2.0f), ImVec2(arrowCenterX, arrowCenterY + 2.0f), ImGui::GetColorU32(ImGuiCol_Text)); if (pressed) { ImGui::OpenPopup(id); } if (XCEngine::Editor::UI::BeginContextMenu(id, ImGuiWindowFlags_AlwaysAutoResize)) { drawPopup(); XCEngine::Editor::UI::EndContextMenu(); } return pressed; } void DrawToolbarOverflowButton( const char* id, const std::function& drawPopup) { const ImVec2 size(18.0f, kConsoleToolbarButtonHeight); ImGui::InvisibleButton(id, size); const bool hovered = ImGui::IsItemHovered(); const bool held = ImGui::IsItemActive(); const bool pressed = ImGui::IsItemClicked(ImGuiMouseButton_Left); const ImVec2 min = ImGui::GetItemRectMin(); const ImVec2 max = ImGui::GetItemRectMax(); ImDrawList* drawList = ImGui::GetWindowDrawList(); drawList->AddRectFilled( min, max, ImGui::GetColorU32( held ? XCEngine::Editor::UI::ToolbarButtonActiveColor() : hovered ? XCEngine::Editor::UI::ToolbarButtonHoveredColor(false) : XCEngine::Editor::UI::ToolbarButtonColor(false)), 2.0f); const float cx = (min.x + max.x) * 0.5f; const float startY = min.y + size.y * 0.5f - 4.0f; for (int i = 0; i < 3; ++i) { drawList->AddCircleFilled( ImVec2(cx, startY + i * 4.0f), 1.2f, ImGui::GetColorU32(ImGuiCol_Text), 8); } if (pressed) { ImGui::OpenPopup(id); } if (XCEngine::Editor::UI::BeginContextMenu(id, ImGuiWindowFlags_AlwaysAutoResize)) { drawPopup(); XCEngine::Editor::UI::EndContextMenu(); } } bool DrawToolbarToggleButton( const char* id, const char* label, bool& active, float width = 0.0f) { const ImVec2 textSize = ImGui::CalcTextSize(label); const ImVec2 size( width > 0.0f ? width : textSize.x + 18.0f, kConsoleToolbarButtonHeight); ImGui::InvisibleButton(id, size); const bool hovered = ImGui::IsItemHovered(); const bool held = ImGui::IsItemActive(); const bool pressed = ImGui::IsItemClicked(ImGuiMouseButton_Left); if (pressed) { active = !active; } const ImVec2 min = ImGui::GetItemRectMin(); const ImVec2 max = ImGui::GetItemRectMax(); ImDrawList* drawList = ImGui::GetWindowDrawList(); drawList->AddRectFilled( min, max, ImGui::GetColorU32( held ? XCEngine::Editor::UI::ToolbarButtonActiveColor() : hovered ? XCEngine::Editor::UI::ToolbarButtonHoveredColor(active) : XCEngine::Editor::UI::ToolbarButtonColor(active)), 2.0f); drawList->AddText( ImVec2(min.x + (size.x - textSize.x) * 0.5f, min.y + (size.y - textSize.y) * 0.5f), ImGui::GetColorU32(ImGuiCol_Text), label); return pressed; } bool DrawToolbarButton( const char* id, const char* label, float width = 0.0f, bool active = false) { const ImVec2 textSize = ImGui::CalcTextSize(label); const ImVec2 size( width > 0.0f ? width : textSize.x + 18.0f, kConsoleToolbarButtonHeight); ImGui::InvisibleButton(id, size); const bool hovered = ImGui::IsItemHovered(); const bool held = ImGui::IsItemActive(); const bool pressed = ImGui::IsItemClicked(ImGuiMouseButton_Left); const ImVec2 min = ImGui::GetItemRectMin(); const ImVec2 max = ImGui::GetItemRectMax(); ImDrawList* drawList = ImGui::GetWindowDrawList(); drawList->AddRectFilled( min, max, ImGui::GetColorU32( held ? XCEngine::Editor::UI::ToolbarButtonActiveColor() : hovered ? XCEngine::Editor::UI::ToolbarButtonHoveredColor(active) : XCEngine::Editor::UI::ToolbarButtonColor(active)), 2.0f); drawList->AddText( ImVec2(min.x + (size.x - textSize.x) * 0.5f, min.y + (size.y - textSize.y) * 0.5f), ImGui::GetColorU32(ImGuiCol_Text), label); return pressed; } bool DrawCompactCheckedMenuItem(const char* label, bool checked) { if (!label) { return false; } const bool clicked = ImGui::Selectable(label, false, ImGuiSelectableFlags_SpanAvailWidth); if (checked) { const ImRect rect(ImGui::GetItemRectMin(), ImGui::GetItemRectMax()); ImDrawList* drawList = ImGui::GetWindowDrawList(); const ImU32 color = ImGui::GetColorU32(ImGuiCol_CheckMark); const float height = rect.Max.y - rect.Min.y; const float checkWidth = height * 0.28f; const float checkHeight = height * 0.18f; const float x = rect.Max.x - 12.0f; const float y = rect.Min.y + height * 0.52f; drawList->AddLine( ImVec2(x - checkWidth, y - checkHeight * 0.15f), ImVec2(x - checkWidth * 0.42f, y + checkHeight), color, 1.4f); drawList->AddLine( ImVec2(x - checkWidth * 0.42f, y + checkHeight), ImVec2(x + checkWidth, y - checkHeight), color, 1.4f); } return clicked; } void DrawToolbarArrowDropdownButton( const char* id, float width, const std::function& drawPopup, bool active = false) { const ImVec2 size(width, kConsoleToolbarButtonHeight); ImGui::InvisibleButton(id, size); const bool hovered = ImGui::IsItemHovered(); const bool held = ImGui::IsItemActive(); const bool pressed = ImGui::IsItemClicked(ImGuiMouseButton_Left); const ImVec2 min = ImGui::GetItemRectMin(); const ImVec2 max = ImGui::GetItemRectMax(); ImDrawList* drawList = ImGui::GetWindowDrawList(); drawList->AddRectFilled( min, max, ImGui::GetColorU32( held ? XCEngine::Editor::UI::ToolbarButtonActiveColor() : hovered ? XCEngine::Editor::UI::ToolbarButtonHoveredColor(active) : XCEngine::Editor::UI::ToolbarButtonColor(active)), 2.0f); const float arrowCenterX = (min.x + max.x) * 0.5f; const float arrowCenterY = min.y + size.y * 0.5f + 1.0f; drawList->AddTriangleFilled( ImVec2(arrowCenterX - 3.0f, arrowCenterY - 2.0f), ImVec2(arrowCenterX + 3.0f, arrowCenterY - 2.0f), ImVec2(arrowCenterX, arrowCenterY + 2.0f), ImGui::GetColorU32(ImGuiCol_Text)); if (pressed) { ImGui::OpenPopup(id); } if (XCEngine::Editor::UI::BeginContextMenu(id, ImGuiWindowFlags_AlwaysAutoResize)) { drawPopup(); XCEngine::Editor::UI::EndContextMenu(); } } bool DrawSeverityToggleButton( const char* id, ConsoleSeverityVisual severity, size_t count, bool& active, const char* tooltip) { const float originalCursorY = ImGui::GetCursorPosY(); ImGui::SetCursorPosY((std::max)(0.0f, originalCursorY - kConsoleToolbarRowPaddingY)); const std::string countText = std::to_string(count); const ImVec2 countSize = ImGui::CalcTextSize(countText.c_str()); const ImVec2 padding(7.0f, 3.0f); const float iconRadius = 10.0f; const float gap = 5.0f; const float buttonHeight = kConsoleToolbarHeight; const ImVec2 size( (std::max)( kConsoleCounterWidth, padding.x * 2.0f + iconRadius * 2.0f + gap + countSize.x), buttonHeight); ImGui::InvisibleButton(id, size); const bool hovered = ImGui::IsItemHovered(); const bool held = ImGui::IsItemActive(); const bool pressed = ImGui::IsItemClicked(ImGuiMouseButton_Left); if (pressed) { active = !active; } const ImVec2 min = ImGui::GetItemRectMin(); const ImVec2 max = ImGui::GetItemRectMax(); const ImU32 backgroundColor = ImGui::GetColorU32( held ? XCEngine::Editor::UI::ToolbarButtonActiveColor() : hovered ? XCEngine::Editor::UI::ToolbarButtonHoveredColor(active) : XCEngine::Editor::UI::ToolbarButtonColor(active)); ImDrawList* drawList = ImGui::GetWindowDrawList(); drawList->AddRectFilled(min, max, backgroundColor, 2.0f); const ImVec2 iconCenter(min.x + padding.x + iconRadius, min.y + size.y * 0.5f); DrawSeverityIcon(drawList, iconCenter, iconRadius, severity, true); drawList->AddText( ImVec2(iconCenter.x + iconRadius + gap, min.y + (size.y - countSize.y) * 0.5f), ImGui::GetColorU32(ImGuiCol_Text), countText.c_str()); if (hovered && tooltip && tooltip[0] != '\0') { ImGui::SetTooltip("%s", tooltip); } return pressed; } float CalculateSeverityToggleButtonWidth(size_t count) { const std::string countText = std::to_string(count); const ImVec2 countSize = ImGui::CalcTextSize(countText.c_str()); const ImVec2 padding(7.0f, 3.0f); const float iconRadius = 10.0f; const float gap = 5.0f; return (std::max)( kConsoleCounterWidth, padding.x * 2.0f + iconRadius * 2.0f + gap + countSize.x); } ConsoleRowInteraction DrawConsoleRow(const ConsoleRowData& row, bool selected) { ConsoleRowInteraction interaction; const float rowHeight = kConsoleRowHeight; const float availableWidth = (std::max)(ImGui::GetContentRegionAvail().x, 1.0f); ImGui::InvisibleButton("##ConsoleRow", ImVec2(availableWidth, rowHeight)); interaction.clicked = ImGui::IsItemClicked(ImGuiMouseButton_Left); interaction.rightClicked = ImGui::IsItemClicked(ImGuiMouseButton_Right); interaction.doubleClicked = ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left); const bool hovered = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem); const ImVec2 min = ImGui::GetItemRectMin(); const ImVec2 max = ImGui::GetItemRectMax(); ImDrawList* drawList = ImGui::GetWindowDrawList(); if (selected) { drawList->AddRectFilled(min, max, ImGui::GetColorU32(XCEngine::Editor::UI::ConsoleRowSelectedFillColor())); } else if (hovered) { drawList->AddRectFilled(min, max, ImGui::GetColorU32(XCEngine::Editor::UI::ConsoleRowHoverFillColor())); } drawList->AddLine( ImVec2(min.x, max.y - 0.5f), ImVec2(max.x, max.y - 0.5f), ImGui::GetColorU32(ImVec4(0.0f, 0.0f, 0.0f, 0.28f))); const ConsoleSeverityVisual severity = ResolveSeverity(row.entry.level); const ImVec2 iconCenter(min.x + 10.0f, min.y + rowHeight * 0.5f); DrawSeverityIcon(drawList, iconCenter, 4.5f, severity); float rightEdge = max.x - 6.0f; if (row.count > 1) { const std::string countText = std::to_string(row.count); const ImVec2 countSize = ImGui::CalcTextSize(countText.c_str()); const ImVec2 badgeMin(rightEdge - countSize.x - 8.0f, min.y + 3.0f); const ImVec2 badgeMax(rightEdge, max.y - 3.0f); drawList->AddRectFilled( badgeMin, badgeMax, ImGui::GetColorU32(XCEngine::Editor::UI::ConsoleCountBadgeBackgroundColor()), 2.0f); drawList->AddText( ImVec2( badgeMin.x + (badgeMax.x - badgeMin.x - countSize.x) * 0.5f, badgeMin.y + (badgeMax.y - badgeMin.y - countSize.y) * 0.5f), ImGui::GetColorU32(XCEngine::Editor::UI::ConsoleCountBadgeTextColor()), countText.c_str()); rightEdge = badgeMin.x - 6.0f; } const std::string summary = XCEngine::Editor::UI::BuildConsoleSummaryText(row.entry); const ImVec2 textMin(min.x + 22.0f, min.y); const ImVec2 textMax((std::max)(textMin.x, rightEdge), max.y); drawList->PushClipRect(textMin, textMax, true); drawList->AddText( ImVec2(textMin.x, min.y + (rowHeight - ImGui::GetTextLineHeight()) * 0.5f), ImGui::GetColorU32(ImGuiCol_Text), summary.c_str()); drawList->PopClipRect(); if (hovered) { const std::string sourceText = XCEngine::Editor::UI::BuildConsoleSourceText(row.entry); XCEngine::Editor::UI::BeginTitledTooltip(XCEngine::Editor::UI::ConsoleSeverityLabel(row.entry.level)); ImGui::PushTextWrapPos(ImGui::GetFontSize() * 28.0f); ImGui::TextUnformatted(summary.c_str()); ImGui::PopTextWrapPos(); if (!sourceText.empty() || row.count > 1 || CanOpenSourceLocation(row.entry)) { ImGui::Separator(); if (!sourceText.empty()) { ImGui::TextColored(XCEngine::Editor::UI::ConsoleSecondaryTextColor(), "%s", sourceText.c_str()); } if (row.count > 1) { ImGui::TextColored( XCEngine::Editor::UI::ConsoleSecondaryTextColor(), "Occurrences: %zu", row.count); } if (CanOpenSourceLocation(row.entry)) { ImGui::TextColored( XCEngine::Editor::UI::ConsoleSecondaryTextColor(), "Double-click to open source"); } } XCEngine::Editor::UI::EndTitledTooltip(); } return interaction; } std::string FormatTimestamp(uint64_t timestamp) { if (timestamp == 0) { return {}; } const std::time_t seconds = static_cast(timestamp); std::tm localTime = {}; localtime_s(&localTime, &seconds); char buffer[32] = {}; if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &localTime) == 0) { return std::to_string(timestamp); } return buffer; } bool TryResolveSourcePath(const LogEntry& entry, std::filesystem::path& outPath) { const std::string filePath = entry.file.CStr(); if (filePath.empty()) { return false; } std::error_code ec; std::filesystem::path path(XCEngine::Editor::Platform::Utf8ToWide(filePath)); if (path.is_relative()) { path = std::filesystem::current_path(ec) / path; ec.clear(); } path = std::filesystem::weakly_canonical(path, ec); if (ec) { path = std::filesystem::path(XCEngine::Editor::Platform::Utf8ToWide(filePath)).lexically_normal(); } if (!std::filesystem::exists(path)) { return false; } outPath = path; return true; } bool CanOpenSourceLocation(const LogEntry& entry) { std::filesystem::path resolvedPath; return TryResolveSourcePath(entry, resolvedPath); } bool OpenSourceLocation(const LogEntry& entry) { std::filesystem::path resolvedPath; if (!TryResolveSourcePath(entry, resolvedPath)) { return false; } const HINSTANCE result = ShellExecuteW( nullptr, L"open", resolvedPath.c_str(), nullptr, resolvedPath.parent_path().c_str(), SW_SHOWNORMAL); return reinterpret_cast(result) > 32; } void CopySummaryToClipboard(const ConsoleRowData& row) { const std::string copyText = XCEngine::Editor::UI::BuildConsoleLogText(row.entry); ImGui::SetClipboardText(copyText.c_str()); } void CopyToClipboard(const ConsoleRowData& row) { const std::string copyText = XCEngine::Editor::UI::BuildConsoleCopyText(row.entry); ImGui::SetClipboardText(copyText.c_str()); } std::string BuildDetailsTraceText(const ConsoleRowData& row) { std::string traceText; const LogEntry& entry = row.entry; const std::string fileText = entry.file.CStr(); const std::string functionText = entry.function.CStr(); const std::string timeText = FormatTimestamp(entry.timestamp); if (!functionText.empty() || !fileText.empty()) { traceText += "at "; traceText += functionText.empty() ? "" : functionText; if (!fileText.empty()) { traceText += " ("; traceText += fileText; if (entry.line > 0) { traceText += ":"; traceText += std::to_string(entry.line); } traceText += ")"; } traceText += "\n"; } else { traceText += "No source location captured.\n"; } const char* category = XCEngine::Debug::LogCategoryToString(entry.category); if (category && category[0] != '\0') { traceText += "category: "; traceText += category; traceText += "\n"; } traceText += "thread: "; traceText += std::to_string(entry.threadId); traceText += "\n"; if (!timeText.empty()) { traceText += "time: "; traceText += timeText; traceText += "\n"; } if (row.count > 1) { traceText += "occurrences: "; traceText += std::to_string(row.count); traceText += "\n"; } return traceText; } } // namespace namespace XCEngine { namespace Editor { ConsolePanel::ConsolePanel() : Panel("Console") { } void ConsolePanel::OnAttach() { if (!m_context) { return; } if (m_detailsHeight <= 0.0f) { m_detailsHeight = UI::ConsoleDetailsDefaultHeight(); } EditorConsoleSink* sink = EditorConsoleSink::GetInstance(); m_lastSeenRevision = sink ? sink->GetRevision() : 0; m_lastErrorPauseScanSerial = sink ? FindLatestSerial(sink->GetRecords()) : 0; if (!m_playModeStartedHandlerId) { m_playModeStartedHandlerId = m_context->GetEventBus().Subscribe( [this](const PlayModeStartedEvent&) { HandlePlayModeStarted(); }); } if (!m_playModeStoppedHandlerId) { m_playModeStoppedHandlerId = m_context->GetEventBus().Subscribe( [this](const PlayModeStoppedEvent&) { HandlePlayModeStopped(); }); } if (!m_playModePausedHandlerId) { m_playModePausedHandlerId = m_context->GetEventBus().Subscribe( [this](const PlayModePausedEvent&) { HandlePlayModePaused(); }); } } void ConsolePanel::OnDetach() { if (!m_context) { return; } if (m_playModeStartedHandlerId) { m_context->GetEventBus().Unsubscribe(m_playModeStartedHandlerId); m_playModeStartedHandlerId = 0; } if (m_playModeStoppedHandlerId) { m_context->GetEventBus().Unsubscribe(m_playModeStoppedHandlerId); m_playModeStoppedHandlerId = 0; } if (m_playModePausedHandlerId) { m_context->GetEventBus().Unsubscribe(m_playModePausedHandlerId); m_playModePausedHandlerId = 0; } } void ConsolePanel::HandlePlayModeStarted() { m_playModeActive = true; m_playModePaused = false; EditorConsoleSink* sink = EditorConsoleSink::GetInstance(); if (!sink) { m_lastErrorPauseScanSerial = 0; return; } if (m_filterState.ClearOnPlay()) { sink->Clear(); m_selectedSerial = 0; m_selectedEntryKey.clear(); m_lastErrorPauseScanSerial = 0; return; } m_lastErrorPauseScanSerial = FindLatestSerial(sink->GetRecords()); } void ConsolePanel::HandlePlayModeStopped() { m_playModeActive = false; m_playModePaused = false; } void ConsolePanel::HandlePlayModePaused() { m_playModeActive = true; m_playModePaused = true; } void ConsolePanel::Render() { UI::PanelWindowScope panel(m_name.c_str()); if (!panel.IsOpen()) { return; } EditorConsoleSink* sink = EditorConsoleSink::GetInstance(); if (!sink) { UI::PanelContentScope content("ConsoleUnavailable", UI::DefaultPanelContentPadding()); if (content.IsOpen()) { UI::DrawEmptyState("Console unavailable"); } Actions::ObserveInactiveActionRoute(*m_context); return; } const std::vector records = sink->GetRecords(); const uint64_t revision = sink->GetRevision(); const bool hasNewRevision = revision != m_lastSeenRevision; m_lastSeenRevision = revision; if (m_playModeActive && m_filterState.ErrorPause() && !m_playModePaused) { bool shouldPause = false; uint64_t latestSerial = m_lastErrorPauseScanSerial; for (const EditorConsoleRecord& record : records) { latestSerial = (std::max)(latestSerial, record.serial); if (record.serial > m_lastErrorPauseScanSerial && IsErrorLevel(record.entry.level)) { shouldPause = true; } } if (shouldPause) { m_context->GetEventBus().Publish(PlayModePausedEvent{}); m_playModePaused = true; } m_lastErrorPauseScanSerial = latestSerial; } else { m_lastErrorPauseScanSerial = FindLatestSerial(records); } const UI::SearchQuery searchQuery(m_searchBuffer); ConsoleSeverityCounts counts; std::vector rows = BuildVisibleRows(records, m_filterState, searchQuery, counts); const ConsoleRowData* selectedRow = ResolveSelectedRow(rows, m_selectedSerial, m_selectedEntryKey); if (selectedRow) { m_selectedEntryKey = selectedRow->entryKey; } else if (m_selectedSerial == 0) { m_selectedEntryKey.clear(); } const bool panelFocused = ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows); bool scrollSelectionIntoView = false; if (panelFocused && !ImGui::GetIO().WantTextInput) { if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_F, false)) { m_requestSearchFocus = true; } if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C, false) && selectedRow) { CopyToClipboard(*selectedRow); } if (ImGui::IsKeyPressed(ImGuiKey_UpArrow, false) && SelectRelativeRow(rows, m_selectedSerial, -1)) { scrollSelectionIntoView = true; } if (ImGui::IsKeyPressed(ImGuiKey_DownArrow, false) && SelectRelativeRow(rows, m_selectedSerial, 1)) { scrollSelectionIntoView = true; } } if (scrollSelectionIntoView) { selectedRow = ResolveSelectedRow(rows, m_selectedSerial, m_selectedEntryKey); if (selectedRow) { m_selectedEntryKey = selectedRow->entryKey; } } const float logFilterWidth = CalculateSeverityToggleButtonWidth(counts.logCount); const float warningFilterWidth = CalculateSeverityToggleButtonWidth(counts.warningCount); const float errorFilterWidth = CalculateSeverityToggleButtonWidth(counts.errorCount); const float severityGroupWidth = logFilterWidth + warningFilterWidth + errorFilterWidth; UI::PanelToolbarScope toolbar( "ConsoleToolbar", kConsoleToolbarHeight, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse, true, ImVec2(6.0f, kConsoleToolbarRowPaddingY), ImVec2(kConsoleToolbarItemSpacing, 0.0f), kConsoleToolbarBackgroundColor); ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, ImVec2(0.0f, 0.0f)); if (toolbar.IsOpen() && ImGui::BeginTable( "##ConsoleToolbarLayout", 3, ImGuiTableFlags_NoSavedSettings | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("##Primary", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##Search", ImGuiTableColumnFlags_WidthFixed, kConsoleSearchWidth); ImGui::TableSetupColumn("##Severity", ImGuiTableColumnFlags_WidthFixed, severityGroupWidth); ImGui::TableNextRow(); ImGui::TableNextColumn(); if (DrawToolbarButton("##ConsoleClearButton", "Clear", 42.0f)) { sink->Clear(); m_selectedSerial = 0; m_selectedEntryKey.clear(); } ImGui::SameLine(0.0f, 1.0f); DrawToolbarArrowDropdownButton("##ConsoleClearOptions", 16.0f, [&]() { if (DrawCompactCheckedMenuItem("Clear on Play", m_filterState.ClearOnPlay())) { m_filterState.ClearOnPlay() = !m_filterState.ClearOnPlay(); } }); ImGui::SameLine(0.0f, kConsoleToolbarItemSpacing); DrawToolbarToggleButton("##ConsoleCollapseToggle", "Collapse", m_filterState.Collapse(), 64.0f); ImGui::SameLine(0.0f, kConsoleToolbarItemSpacing); DrawToolbarToggleButton("##ConsoleErrorPauseToggle", "Error Pause", m_filterState.ErrorPause(), 82.0f); ImGui::SameLine(0.0f, kConsoleToolbarItemSpacing); DrawToolbarDropdownButton("##ConsoleSourceDropdown", "Editor", 58.0f, [&]() { ImGui::MenuItem("Editor", nullptr, true, true); ImGui::MenuItem("Player", nullptr, false, false); }); ImGui::TableNextColumn(); if (m_requestSearchFocus) { ImGui::SetKeyboardFocusHere(); m_requestSearchFocus = false; } UI::ToolbarSearchField("##ConsoleSearch", "Search", m_searchBuffer, sizeof(m_searchBuffer)); ImGui::TableNextColumn(); DrawSeverityToggleButton("##ConsoleLogFilter", ConsoleSeverityVisual::Log, counts.logCount, m_filterState.ShowLog(), "Log"); ImGui::SameLine(0.0f, 0.0f); DrawSeverityToggleButton("##ConsoleWarningFilter", ConsoleSeverityVisual::Warning, counts.warningCount, m_filterState.ShowWarning(), "Warnings"); ImGui::SameLine(0.0f, 0.0f); DrawSeverityToggleButton("##ConsoleErrorFilter", ConsoleSeverityVisual::Error, counts.errorCount, m_filterState.ShowError(), "Errors"); ImGui::EndTable(); } ImGui::PopStyleVar(); UI::PanelContentScope content("ConsoleRoot", ImVec2(0.0f, 0.0f)); if (!content.IsOpen()) { Actions::ObserveInactiveActionRoute(*m_context); return; } const float splitterThickness = UI::PanelSplitterHitThickness(); const float totalHeight = ImGui::GetContentRegionAvail().y; const float minDetailsHeight = UI::ConsoleDetailsMinHeight(); const float minListHeight = UI::ConsoleListMinHeight(); const float maxDetailsHeight = (std::max)(minDetailsHeight, totalHeight - minListHeight - splitterThickness); m_detailsHeight = std::clamp(m_detailsHeight, minDetailsHeight, maxDetailsHeight); const float listHeight = (std::max)(minListHeight, totalHeight - m_detailsHeight - splitterThickness); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); ImGui::PushStyleColor(ImGuiCol_ChildBg, UI::ConsoleListBackgroundColor()); const bool listOpen = ImGui::BeginChild( "ConsoleLogList", ImVec2(0.0f, listHeight), false, ImGuiWindowFlags_HorizontalScrollbar); ImGui::PopStyleColor(); if (listOpen) { const bool wasAtBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f; bool openSelectedSource = false; ImGuiListClipper clipper; clipper.Begin(static_cast(rows.size()), kConsoleRowHeight); while (clipper.Step()) { for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) { ConsoleRowData& row = rows[static_cast(i)]; ImGui::PushID(static_cast(row.serial)); const bool selected = row.serial == m_selectedSerial; const ConsoleRowInteraction interaction = DrawConsoleRow(row, selected); if (interaction.clicked || interaction.rightClicked) { m_selectedSerial = row.serial; m_selectedEntryKey = row.entryKey; selectedRow = &row; } if (interaction.doubleClicked) { openSelectedSource = true; } if (scrollSelectionIntoView && row.serial == m_selectedSerial) { ImGui::SetScrollHereY(0.5f); } if (UI::BeginContextMenuForLastItem("##ConsoleRowContext")) { Actions::DrawMenuAction(Actions::MakeAction("Copy", "Ctrl+C", false, true), [&]() { CopySummaryToClipboard(row); }); Actions::DrawMenuAction(Actions::MakeAction("Copy Full", nullptr, false, true), [&]() { CopyToClipboard(row); }); Actions::DrawMenuAction( Actions::MakeAction("Open Source", nullptr, false, CanOpenSourceLocation(row.entry)), [&]() { OpenSourceLocation(row.entry); }); Actions::DrawMenuSeparator(); Actions::DrawMenuAction(Actions::MakeClearConsoleAction(), [&]() { sink->Clear(); m_selectedSerial = 0; m_selectedEntryKey.clear(); }); XCEngine::Editor::UI::EndContextMenu(); } ImGui::PopID(); } } if (rows.empty()) { UI::DrawEmptyState( searchQuery.Empty() ? "No messages" : "No search results", searchQuery.Empty() ? nullptr : "No console entries match the current search", ImVec2(12.0f, 12.0f)); } if (openSelectedSource && selectedRow != nullptr && !OpenSourceLocation(selectedRow->entry)) { CopyToClipboard(*selectedRow); } if (UI::BeginContextMenuForWindow("##ConsoleBackgroundContext")) { Actions::DrawMenuAction(Actions::MakeClearConsoleAction(), [&]() { sink->Clear(); m_selectedSerial = 0; m_selectedEntryKey.clear(); }); XCEngine::Editor::UI::EndContextMenu(); } if (hasNewRevision && wasAtBottom) { ImGui::SetScrollHereY(1.0f); } } ImGui::EndChild(); ImGui::PopStyleVar(); const UI::SplitterResult splitter = UI::DrawSplitter("##ConsoleDetailsSplitter", UI::SplitterAxis::Horizontal, splitterThickness); if (splitter.active) { m_detailsHeight = std::clamp(m_detailsHeight - splitter.delta, minDetailsHeight, maxDetailsHeight); } selectedRow = ResolveSelectedRow(rows, m_selectedSerial, m_selectedEntryKey); if (selectedRow) { m_selectedEntryKey = selectedRow->entryKey; } ImGui::PushStyleColor(ImGuiCol_ChildBg, UI::ConsoleDetailsBackgroundColor()); const bool detailsOpen = ImGui::BeginChild("ConsoleDetails", ImVec2(0.0f, 0.0f), false); ImGui::PopStyleColor(); if (detailsOpen) { ImGui::PushStyleColor(ImGuiCol_ChildBg, UI::ConsoleDetailsHeaderBackgroundColor()); const bool headerOpen = ImGui::BeginChild( "ConsoleDetailsHeader", ImVec2(0.0f, kConsoleDetailsHeaderHeight), false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); ImGui::PopStyleColor(); if (headerOpen) { if (selectedRow) { const std::string sourceText = UI::BuildConsoleSourceText(selectedRow->entry); const bool canOpenSource = CanOpenSourceLocation(selectedRow->entry); std::string headerText = UI::ConsoleSeverityLabel(selectedRow->entry.level); if (!sourceText.empty()) { headerText += " "; headerText += sourceText; } if (ImGui::BeginTable( "##ConsoleDetailsHeaderLayout", 2, ImGuiTableFlags_NoSavedSettings | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("##Left", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##Right", ImGuiTableColumnFlags_WidthFixed, 128.0f); ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(headerText.c_str()); ImGui::TableNextColumn(); if (selectedRow->count > 1) { ImGui::AlignTextToFramePadding(); ImGui::TextColored(UI::ConsoleSecondaryTextColor(), "x%zu", selectedRow->count); ImGui::SameLine(0.0f, 6.0f); } if (ImGui::SmallButton("Copy")) { CopyToClipboard(*selectedRow); } ImGui::SameLine(0.0f, 4.0f); ImGui::BeginDisabled(!canOpenSource); if (ImGui::SmallButton("Open")) { OpenSourceLocation(selectedRow->entry); } ImGui::EndDisabled(); ImGui::EndTable(); } } else { ImGui::TextColored(UI::ConsoleSecondaryTextColor(), "Stack Trace"); } } ImGui::EndChild(); if (selectedRow) { const std::string message = selectedRow->entry.message.CStr(); const std::string traceText = BuildDetailsTraceText(*selectedRow); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f, 8.0f)); const bool bodyOpen = ImGui::BeginChild( "ConsoleDetailsBody", ImVec2(0.0f, 0.0f), false, ImGuiWindowFlags_HorizontalScrollbar); ImGui::PopStyleVar(); if (bodyOpen) { ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x); ImGui::TextUnformatted(message.c_str()); ImGui::PopTextWrapPos(); ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, UI::ConsoleSecondaryTextColor()); ImGui::TextUnformatted(traceText.c_str()); ImGui::PopStyleColor(); } ImGui::EndChild(); } else { UI::PanelContentScope detailsContent("ConsoleDetailsEmpty", ImVec2(10.0f, 8.0f)); if (detailsContent.IsOpen()) { UI::DrawEmptyState("No message selected", "Select a console entry to inspect its stack trace"); } } } ImGui::EndChild(); Actions::ObserveInactiveActionRoute(*m_context); } } // namespace Editor } // namespace XCEngine