feat: overhaul editor console panel and diagnostics

This commit is contained in:
2026-03-31 21:28:16 +08:00
parent 6d3a90ef74
commit ad237cb81e
9 changed files with 1176 additions and 20 deletions

View File

@@ -9,15 +9,33 @@ namespace XCEngine {
namespace Editor {
namespace Actions {
inline ActionBinding MakeConsoleCollapseAction(bool active, bool enabled = true) {
return MakeAction("Collapse", nullptr, active, enabled);
}
inline ActionBinding MakeConsoleClearOnPlayAction(bool active, bool enabled = true) {
return MakeAction("Clear on Play", nullptr, active, enabled);
}
inline ActionBinding MakeConsoleErrorPauseAction(bool active, bool enabled = true) {
return MakeAction("Error Pause", nullptr, active, enabled);
}
inline void DrawConsoleToolbarActions(Debug::EditorConsoleSink& sink, UI::ConsoleFilterState& filterState) {
if (DrawToolbarAction(MakeClearConsoleAction())) {
sink.Clear();
}
ImGui::SameLine();
DrawToolbarToggleAction(MakeConsoleCollapseAction(filterState.Collapse()), filterState.Collapse());
ImGui::SameLine();
DrawToolbarToggleAction(MakeConsoleClearOnPlayAction(filterState.ClearOnPlay()), filterState.ClearOnPlay());
ImGui::SameLine();
DrawToolbarToggleAction(MakeConsoleErrorPauseAction(filterState.ErrorPause()), filterState.ErrorPause());
ImGui::SameLine();
UI::DrawToolbarLabel("Filter");
ImGui::SameLine();
DrawToolbarToggleAction(MakeConsoleInfoFilterAction(filterState.ShowInfo()), filterState.ShowInfo());
DrawToolbarToggleAction(MakeConsoleInfoFilterAction(filterState.ShowLog()), filterState.ShowLog());
ImGui::SameLine();
DrawToolbarToggleAction(MakeConsoleWarningFilterAction(filterState.ShowWarning()), filterState.ShowWarning());
ImGui::SameLine();

View File

@@ -21,13 +21,18 @@ EditorConsoleSink::~EditorConsoleSink() {
}
void EditorConsoleSink::Log(const LogEntry& entry) {
std::lock_guard<std::mutex> lock(m_mutex);
if (m_logs.size() >= MAX_LOGS) {
m_logs.erase(m_logs.begin());
std::function<void()> callback;
{
std::lock_guard<std::mutex> lock(m_mutex);
if (m_logs.size() >= MAX_LOGS) {
m_logs.erase(m_logs.begin());
}
m_logs.push_back(EditorConsoleRecord{ m_nextSerial++, entry });
++m_revision;
callback = m_callback;
}
m_logs.push_back(entry);
if (m_callback) {
m_callback();
if (callback) {
callback();
}
}
@@ -35,16 +40,43 @@ void EditorConsoleSink::Flush() {
}
std::vector<LogEntry> EditorConsoleSink::GetLogs() const {
std::lock_guard<std::mutex> lock(m_mutex);
std::vector<LogEntry> logs;
logs.reserve(m_logs.size());
for (const EditorConsoleRecord& record : m_logs) {
logs.push_back(record.entry);
}
return logs;
}
std::vector<EditorConsoleRecord> EditorConsoleSink::GetRecords() const {
std::lock_guard<std::mutex> lock(m_mutex);
return m_logs;
}
void EditorConsoleSink::Clear() {
uint64_t EditorConsoleSink::GetRevision() const {
std::lock_guard<std::mutex> lock(m_mutex);
m_logs.clear();
return m_revision;
}
void EditorConsoleSink::Clear() {
std::function<void()> callback;
{
std::lock_guard<std::mutex> lock(m_mutex);
if (m_logs.empty()) {
return;
}
m_logs.clear();
++m_revision;
callback = m_callback;
}
if (callback) {
callback();
}
}
void EditorConsoleSink::SetCallback(std::function<void()> callback) {
std::lock_guard<std::mutex> lock(m_mutex);
m_callback = std::move(callback);
}

View File

@@ -9,6 +9,11 @@
namespace XCEngine {
namespace Debug {
struct EditorConsoleRecord {
uint64_t serial = 0;
LogEntry entry = {};
};
class EditorConsoleSink : public ILogSink {
public:
static EditorConsoleSink* GetInstance();
@@ -20,13 +25,17 @@ public:
void Flush() override;
std::vector<LogEntry> GetLogs() const;
std::vector<EditorConsoleRecord> GetRecords() const;
uint64_t GetRevision() const;
void Clear();
void SetCallback(std::function<void()> callback);
private:
mutable std::mutex m_mutex;
std::vector<LogEntry> m_logs;
std::vector<EditorConsoleRecord> m_logs;
std::function<void()> m_callback;
uint64_t m_nextSerial = 1;
uint64_t m_revision = 0;
static EditorConsoleSink* s_instance;
static constexpr size_t MAX_LOGS = 1000;
};

View File

@@ -2,6 +2,9 @@
#include "Platform/Win32Utf8.h"
#ifdef _MSC_VER
#include <crtdbg.h>
#endif
#include <dbghelp.h>
#include <stdio.h>
#include <string>
@@ -15,6 +18,46 @@ inline std::string GetExecutableLogPath(const char* fileName) {
return GetExecutableDirectoryUtf8() + "\\" + fileName;
}
#ifdef _MSC_VER
inline void WriteInvalidParameterReport(
const wchar_t* expression,
const wchar_t* function,
const wchar_t* file,
unsigned int line) {
const std::string logPath = GetExecutableLogPath("crash.log");
FILE* logFile = nullptr;
fopen_s(&logFile, logPath.c_str(), "a");
if (logFile != nullptr) {
fwprintf(
logFile,
L"[CRT] Invalid parameter. function=%s file=%s line=%u expression=%s\n",
function != nullptr ? function : L"(null)",
file != nullptr ? file : L"(null)",
line,
expression != nullptr ? expression : L"(null)");
fclose(logFile);
}
fwprintf(
stderr,
L"[CRT] Invalid parameter. function=%s file=%s line=%u expression=%s\n",
function != nullptr ? function : L"(null)",
file != nullptr ? file : L"(null)",
line,
expression != nullptr ? expression : L"(null)");
fflush(stderr);
}
inline void InvalidParameterHandler(
const wchar_t* expression,
const wchar_t* function,
const wchar_t* file,
unsigned int line,
uintptr_t) {
WriteInvalidParameterReport(expression, function, file, line);
}
#endif
inline void WriteCrashStackTrace(FILE* file) {
if (file == nullptr) {
return;
@@ -84,11 +127,24 @@ inline LONG WINAPI CrashExceptionFilter(EXCEPTION_POINTERS* exceptionPointers) {
inline void InstallCrashExceptionFilter() {
SetUnhandledExceptionFilter(CrashExceptionFilter);
#ifdef _MSC_VER
_set_invalid_parameter_handler(InvalidParameterHandler);
#endif
}
inline void RedirectStderrToExecutableLog() {
const std::string stderrPath = GetExecutableLogPath("stderr.log");
freopen(stderrPath.c_str(), "w", stderr);
#ifdef _MSC_VER
_CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR);
_CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR);
_CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR);
#endif
}
} // namespace Platform

View File

@@ -4,6 +4,7 @@
#include <XCEngine/Debug/LogEntry.h>
#include <XCEngine/Debug/LogLevel.h>
#include <filesystem>
#include <string>
namespace XCEngine {
@@ -26,11 +27,77 @@ inline const char* ConsoleLogPrefix(::XCEngine::Debug::LogLevel level) {
return "[LOG] ";
}
inline const char* ConsoleSeverityLabel(::XCEngine::Debug::LogLevel level) {
switch (level) {
case ::XCEngine::Debug::LogLevel::Verbose:
case ::XCEngine::Debug::LogLevel::Debug:
case ::XCEngine::Debug::LogLevel::Info:
return "Log";
case ::XCEngine::Debug::LogLevel::Warning:
return "Warning";
case ::XCEngine::Debug::LogLevel::Error:
case ::XCEngine::Debug::LogLevel::Fatal:
return "Error";
}
return "Log";
}
inline std::string BuildConsoleSummaryText(const ::XCEngine::Debug::LogEntry& log) {
return log.message.CStr();
}
inline std::string BuildConsoleSourceText(const ::XCEngine::Debug::LogEntry& log) {
std::string source;
const char* category = ::XCEngine::Debug::LogCategoryToString(log.category);
if (category && category[0] != '\0') {
source += "[";
source += category;
source += "]";
}
const std::string filePath = log.file.CStr();
if (!filePath.empty()) {
if (!source.empty()) {
source += " ";
}
source += std::filesystem::path(filePath).filename().string();
if (log.line > 0) {
source += ":";
source += std::to_string(log.line);
}
}
return source;
}
inline std::string BuildConsoleLogText(const ::XCEngine::Debug::LogEntry& log) {
const char* category = ::XCEngine::Debug::LogCategoryToString(log.category);
return std::string(ConsoleLogPrefix(log.level)) + "[" + category + "] " + log.message.CStr();
}
inline std::string BuildConsoleCopyText(const ::XCEngine::Debug::LogEntry& log) {
std::string text = BuildConsoleLogText(log);
if (log.file.Length() > 0) {
text += "\nFile: ";
text += log.file.CStr();
if (log.line > 0) {
text += ":";
text += std::to_string(log.line);
}
}
if (log.function.Length() > 0) {
text += "\nFunction: ";
text += log.function.CStr();
}
text += "\nThread: ";
text += std::to_string(log.threadId);
text += "\nTimestamp: ";
text += std::to_string(log.timestamp);
return text;
}
} // namespace UI
} // namespace Editor
} // namespace XCEngine

View File

@@ -39,24 +39,24 @@ inline void DrawDisclosureArrow(ImDrawList* drawList, const ImVec2& min, const I
return;
}
constexpr float kSqrt3 = 1.7320508f;
const ImVec2 center(
static_cast<float>(std::floor((min.x + max.x) * 0.5f)) + 0.5f,
static_cast<float>(std::floor((min.y + max.y) * 0.5f)) + 0.5f);
const float width = max.x - min.x;
const float height = max.y - min.y;
const float radius = (std::max)(
const float halfBaseExtent = (std::max)(
3.0f,
static_cast<float>(std::floor((width < height ? width : height) * DisclosureArrowScale())));
const float baseHalfExtent = radius;
const float tipExtent = (std::max)(2.0f, static_cast<float>(std::floor(radius * 0.70f)));
if (baseHalfExtent < 1.0f || tipExtent < 1.0f) {
const float triangleHeight = kSqrt3 * halfBaseExtent;
if (halfBaseExtent < 1.0f || triangleHeight < 1.0f) {
return;
}
ImVec2 points[3] = {
ImVec2(-baseHalfExtent, -tipExtent),
ImVec2(baseHalfExtent, -tipExtent),
ImVec2(0.0f, tipExtent)
ImVec2(-halfBaseExtent, -triangleHeight / 3.0f),
ImVec2(halfBaseExtent, -triangleHeight / 3.0f),
ImVec2(0.0f, triangleHeight * 2.0f / 3.0f)
};
if (!open) {

View File

@@ -269,7 +269,7 @@ inline float NavigationTreePrefixLabelGap() {
}
inline float DisclosureArrowScale() {
return 0.28f;
return 0.18f;
}
inline ImVec4 NavigationTreePrefixColor(bool selected = false, bool hovered = false) {
@@ -704,6 +704,74 @@ inline ImVec4 ConsoleRowHoverFillColor() {
return ImVec4(1.0f, 1.0f, 1.0f, 0.03f);
}
inline ImVec4 ConsoleRowSelectedFillColor() {
return ImVec4(0.26f, 0.33f, 0.43f, 0.95f);
}
inline ImVec4 ConsoleListBackgroundColor() {
return ImVec4(0.205f, 0.205f, 0.205f, 1.0f);
}
inline ImVec4 ConsoleDetailsBackgroundColor() {
return ImVec4(0.185f, 0.185f, 0.185f, 1.0f);
}
inline ImVec4 ConsoleDetailsHeaderBackgroundColor() {
return ImVec4(0.19f, 0.19f, 0.19f, 1.0f);
}
inline ImVec4 ConsoleSecondaryTextColor() {
return ImVec4(0.57f, 0.57f, 0.57f, 1.0f);
}
inline ImVec4 ConsoleCountBadgeBackgroundColor() {
return ImVec4(0.29f, 0.29f, 0.29f, 1.0f);
}
inline ImVec4 ConsoleCountBadgeTextColor() {
return ImVec4(0.88f, 0.88f, 0.88f, 1.0f);
}
inline ImVec4 ConsoleLogColor() {
return ImVec4(0.77f, 0.77f, 0.77f, 1.0f);
}
inline ImVec4 ConsoleWarningColor() {
return ImVec4(0.93f, 0.72f, 0.26f, 1.0f);
}
inline ImVec4 ConsoleErrorColor() {
return ImVec4(0.92f, 0.37f, 0.33f, 1.0f);
}
inline float ConsoleRowHeight() {
return 22.0f;
}
inline float ConsoleSeverityButtonMinWidth() {
return 54.0f;
}
inline ImVec2 ConsoleSeverityButtonPadding() {
return ImVec2(8.0f, 4.0f);
}
inline float ConsoleBadgeRounding() {
return 3.0f;
}
inline float ConsoleDetailsDefaultHeight() {
return 168.0f;
}
inline float ConsoleDetailsMinHeight() {
return 92.0f;
}
inline float ConsoleListMinHeight() {
return 96.0f;
}
inline float MenuBarStatusRightPadding() {
return 20.0f;
}

View File

@@ -1,7 +1,481 @@
#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 <imgui.h>
#include <shellapi.h>
#include <algorithm>
#include <cctype>
#include <cmath>
#include <ctime>
#include <filesystem>
#include <string>
#include <unordered_map>
#include <vector>
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<char>(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<int>(entry.level));
key.push_back('\x1f');
key += std::to_string(static_cast<int>(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<EditorConsoleRecord>& records) {
uint64_t latestSerial = 0;
for (const EditorConsoleRecord& record : records) {
latestSerial = (std::max)(latestSerial, record.serial);
}
return latestSerial;
}
std::vector<ConsoleRowData> BuildVisibleRows(
const std::vector<EditorConsoleRecord>& records,
const XCEngine::Editor::UI::ConsoleFilterState& filterState,
const std::string& searchText,
ConsoleSeverityCounts& counts) {
std::vector<ConsoleRowData> 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<std::string, size_t> 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<ConsoleRowData>& 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<ConsoleRowData>& rows, uint64_t& selectedSerial, int delta) {
if (rows.empty() || delta == 0) {
return false;
}
int currentIndex = -1;
for (int i = 0; i < static_cast<int>(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<int>(rows.size()) - 1);
} else if (delta > 0) {
nextIndex = 0;
} else {
nextIndex = static_cast<int>(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<float>(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<std::time_t>(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<INT_PTR>(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 {
@@ -9,14 +483,421 @@ 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<PlayModeStartedEvent>(
[this](const PlayModeStartedEvent&) {
HandlePlayModeStarted();
});
}
if (!m_playModeStoppedHandlerId) {
m_playModeStoppedHandlerId = m_context->GetEventBus().Subscribe<PlayModeStoppedEvent>(
[this](const PlayModeStoppedEvent&) {
HandlePlayModeStopped();
});
}
if (!m_playModePausedHandlerId) {
m_playModePausedHandlerId = m_context->GetEventBus().Subscribe<PlayModePausedEvent>(
[this](const PlayModePausedEvent&) {
HandlePlayModePaused();
});
}
}
void ConsolePanel::OnDetach() {
if (!m_context) {
return;
}
if (m_playModeStartedHandlerId) {
m_context->GetEventBus().Unsubscribe<PlayModeStartedEvent>(m_playModeStartedHandlerId);
m_playModeStartedHandlerId = 0;
}
if (m_playModeStoppedHandlerId) {
m_context->GetEventBus().Unsubscribe<PlayModeStoppedEvent>(m_playModeStoppedHandlerId);
m_playModeStoppedHandlerId = 0;
}
if (m_playModePausedHandlerId) {
m_context->GetEventBus().Unsubscribe<PlayModePausedEvent>(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<EditorConsoleRecord> 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<ConsoleRowData> 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<int>(rows.size()), UI::ConsoleRowHeight());
while (clipper.Step()) {
for (int i = clipper.DisplayStart; i < clipper.DisplayEnd; ++i) {
ConsoleRowData& row = rows[static_cast<size_t>(i)];
ImGui::PushID(static_cast<int>(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

View File

@@ -1,6 +1,10 @@
#pragma once
#include "Panel.h"
#include "UI/ConsoleFilterState.h"
#include <cstdint>
#include <string>
namespace XCEngine {
namespace Editor {
@@ -8,7 +12,28 @@ namespace Editor {
class ConsolePanel : public Panel {
public:
ConsolePanel();
void OnAttach() override;
void OnDetach() override;
void Render() override;
private:
void HandlePlayModeStarted();
void HandlePlayModeStopped();
void HandlePlayModePaused();
UI::ConsoleFilterState m_filterState;
char m_searchBuffer[256] = "";
uint64_t m_selectedSerial = 0;
uint64_t m_lastSeenRevision = 0;
uint64_t m_lastErrorPauseScanSerial = 0;
uint64_t m_playModeStartedHandlerId = 0;
uint64_t m_playModeStoppedHandlerId = 0;
uint64_t m_playModePausedHandlerId = 0;
std::string m_selectedEntryKey;
float m_detailsHeight = 0.0f;
bool m_playModeActive = false;
bool m_playModePaused = false;
bool m_requestSearchFocus = false;
};
}