#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; }; 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; }; 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 ToLowerCopy(std::string text) { std::transform(text.begin(), text.end(), text.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); return text; } 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 ToLowerCopy(std::move(haystack)); } bool MatchesSearch(const LogEntry& entry, const std::string& searchText) { if (searchText.empty()) { return true; } return BuildSearchHaystack(entry).find(searchText) != std::string::npos; } 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 std::string& searchText, 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, searchText)) { 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, searchText)) { 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) { 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 DrawSeverityToggleButton( const char* id, ConsoleSeverityVisual severity, size_t count, bool& active, const char* tooltip) { const std::string countText = std::to_string(count); const ImVec2 countSize = ImGui::CalcTextSize(countText.c_str()); const ImVec2 padding = XCEngine::Editor::UI::ConsoleSeverityButtonPadding(); const float iconRadius = 5.0f; const float gap = 8.0f; const ImVec2 size( (std::max)( XCEngine::Editor::UI::ConsoleSeverityButtonMinWidth(), padding.x * 2.0f + iconRadius * 2.0f + gap + countSize.x), ImGui::GetFrameHeight()); 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); 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; } ConsoleRowInteraction DrawConsoleRow(const ConsoleRowData& row, bool selected) { ConsoleRowInteraction interaction; const float rowHeight = XCEngine::Editor::UI::ConsoleRowHeight(); 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())); } const ConsoleSeverityVisual severity = ResolveSeverity(row.entry.level); const ImVec2 iconCenter(min.x + 12.0f, min.y + rowHeight * 0.5f); DrawSeverityIcon(drawList, iconCenter, 5.0f, severity); float rightEdge = max.x - 8.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 - 10.0f, min.y + 3.0f); const ImVec2 badgeMax(rightEdge, max.y - 3.0f); drawList->AddRectFilled( badgeMin, badgeMax, ImGui::GetColorU32(XCEngine::Editor::UI::ConsoleCountBadgeBackgroundColor()), XCEngine::Editor::UI::ConsoleBadgeRounding()); 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 - 8.0f; } const std::string sourceText = XCEngine::Editor::UI::BuildConsoleSourceText(row.entry); if (!sourceText.empty()) { const ImVec2 sourceSize = ImGui::CalcTextSize(sourceText.c_str()); const float sourceWidth = (std::min)(sourceSize.x, availableWidth * 0.32f); const ImVec2 sourceMin(rightEdge - sourceWidth, min.y); const ImVec2 sourceMax(rightEdge, max.y); drawList->PushClipRect(sourceMin, sourceMax, true); drawList->AddText( ImVec2(sourceMin.x, min.y + (rowHeight - sourceSize.y) * 0.5f), ImGui::GetColorU32(XCEngine::Editor::UI::ConsoleSecondaryTextColor()), sourceText.c_str()); drawList->PopClipRect(); rightEdge = sourceMin.x - 10.0f; } const std::string summary = XCEngine::Editor::UI::BuildConsoleSummaryText(row.entry); const ImVec2 textMin(min.x + 24.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(); 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()); } } // 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 std::string searchText = ToLowerCopy(std::string(m_searchBuffer)); ConsoleSeverityCounts counts; std::vector rows = BuildVisibleRows(records, m_filterState, searchText, 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; } } UI::PanelToolbarScope toolbar( "ConsoleToolbar", UI::StandardPanelToolbarHeight(), ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse, true, UI::ToolbarPadding(), UI::ToolbarItemSpacing(), UI::ToolbarBackgroundColor()); if (toolbar.IsOpen() && ImGui::BeginTable( "##ConsoleToolbarLayout", 3, ImGuiTableFlags_NoSavedSettings | ImGuiTableFlags_SizingStretchProp)) { ImGui::TableSetupColumn("##Primary", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("##Search", ImGuiTableColumnFlags_WidthFixed, 240.0f); ImGui::TableSetupColumn("##Severity", ImGuiTableColumnFlags_WidthFixed, 250.0f); ImGui::TableNextRow(); ImGui::TableNextColumn(); if (Actions::DrawToolbarAction(Actions::MakeClearConsoleAction())) { sink->Clear(); m_selectedSerial = 0; m_selectedEntryKey.clear(); } ImGui::SameLine(); Actions::DrawToolbarToggleAction( Actions::MakeConsoleCollapseAction(m_filterState.Collapse()), m_filterState.Collapse()); ImGui::SameLine(); Actions::DrawToolbarToggleAction( Actions::MakeConsoleClearOnPlayAction(m_filterState.ClearOnPlay()), m_filterState.ClearOnPlay()); ImGui::SameLine(); Actions::DrawToolbarToggleAction( Actions::MakeConsoleErrorPauseAction(m_filterState.ErrorPause()), m_filterState.ErrorPause()); 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, 6.0f); DrawSeverityToggleButton("##ConsoleWarningFilter", ConsoleSeverityVisual::Warning, counts.warningCount, m_filterState.ShowWarning(), "Warnings"); ImGui::SameLine(0.0f, 6.0f); DrawSeverityToggleButton("##ConsoleErrorFilter", ConsoleSeverityVisual::Error, counts.errorCount, m_filterState.ShowError(), "Errors"); ImGui::EndTable(); } 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::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()), UI::ConsoleRowHeight()); 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(); }); UI::EndContextMenu(); } ImGui::PopID(); } } if (rows.empty()) { UI::DrawEmptyState( searchText.empty() ? "No messages" : "No search results", searchText.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(); }); UI::EndContextMenu(); } if (hasNewRevision && wasAtBottom) { ImGui::SetScrollHereY(1.0f); } } ImGui::EndChild(); 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, 28.0f), false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); ImGui::PopStyleColor(); if (headerOpen) { if (selectedRow) { const char* severityLabel = UI::ConsoleSeverityLabel(selectedRow->entry.level); const std::string sourceText = UI::BuildConsoleSourceText(selectedRow->entry); ImGui::Text("%s", severityLabel); if (!sourceText.empty()) { ImGui::SameLine(); ImGui::TextColored(UI::ConsoleSecondaryTextColor(), "%s", sourceText.c_str()); } if (selectedRow->count > 1) { const std::string countLabel = "x" + std::to_string(selectedRow->count); ImGui::SameLine(); UI::DrawRightAlignedText(countLabel.c_str(), UI::ConsoleSecondaryTextColor(), 8.0f); } } else { ImGui::TextColored(UI::ConsoleSecondaryTextColor(), "Details"); } } ImGui::EndChild(); if (selectedRow) { const std::string message = selectedRow->entry.message.CStr(); const std::string fileText = selectedRow->entry.file.CStr(); const std::string functionText = selectedRow->entry.function.CStr(); const std::string timeText = FormatTimestamp(selectedRow->entry.timestamp); const std::string sourceText = UI::BuildConsoleSourceText(selectedRow->entry); const std::string threadText = std::to_string(selectedRow->entry.threadId); const std::string lineText = selectedRow->entry.line > 0 ? std::to_string(selectedRow->entry.line) : std::string(); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(6.0f, 6.0f)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f, 8.0f)); const bool bodyOpen = ImGui::BeginChild("ConsoleDetailsBody", ImVec2(0.0f, 0.0f), false); ImGui::PopStyleVar(2); if (bodyOpen) { ImGui::TextWrapped("%s", message.c_str()); ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); if (ImGui::BeginTable("##ConsoleDetailsMeta", 2, ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_NoSavedSettings)) { auto drawMetaRow = [](const char* label, const std::string& value) { if (value.empty()) { return; } ImGui::TableNextRow(); ImGui::TableNextColumn(); ImGui::TextColored(XCEngine::Editor::UI::ConsoleSecondaryTextColor(), "%s", label); ImGui::TableNextColumn(); ImGui::TextWrapped("%s", value.c_str()); }; drawMetaRow("Type", UI::ConsoleSeverityLabel(selectedRow->entry.level)); drawMetaRow("Category", XCEngine::Debug::LogCategoryToString(selectedRow->entry.category)); drawMetaRow("Source", sourceText); drawMetaRow("File", fileText); drawMetaRow("Line", lineText); drawMetaRow("Function", functionText); drawMetaRow("Time", timeText); drawMetaRow("Thread", threadText); ImGui::EndTable(); } ImGui::Spacing(); if (ImGui::Button("Copy", ImVec2(72.0f, 0.0f))) { CopyToClipboard(*selectedRow); } ImGui::SameLine(); ImGui::BeginDisabled(!CanOpenSourceLocation(selectedRow->entry)); if (ImGui::Button("Open Source", ImVec2(96.0f, 0.0f))) { OpenSourceLocation(selectedRow->entry); } ImGui::EndDisabled(); } ImGui::EndChild(); } else { UI::PanelContentScope detailsContent("ConsoleDetailsEmpty", ImVec2(10.0f, 8.0f)); if (detailsContent.IsOpen()) { UI::DrawEmptyState("No message selected"); } } } ImGui::EndChild(); Actions::ObserveInactiveActionRoute(*m_context); } } // namespace Editor } // namespace XCEngine