From f8805dabe7b1824c1dfc7fe182a84b90cae3eabd Mon Sep 17 00:00:00 2001 From: Michal Pikulski Date: Tue, 11 Nov 2025 08:30:21 +0100 Subject: [PATCH] Custom logger --- Assets/Editor/CustomLogWindow.cs | 512 ++++++++++++++++++++++++++ Assets/Editor/CustomLogWindow.cs.meta | 3 + Assets/Scripts/Core/Logging.cs | 139 ++++++- 3 files changed, 648 insertions(+), 6 deletions(-) create mode 100644 Assets/Editor/CustomLogWindow.cs create mode 100644 Assets/Editor/CustomLogWindow.cs.meta diff --git a/Assets/Editor/CustomLogWindow.cs b/Assets/Editor/CustomLogWindow.cs new file mode 100644 index 00000000..5e936742 --- /dev/null +++ b/Assets/Editor/CustomLogWindow.cs @@ -0,0 +1,512 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEngine; +using Core; + +namespace Editor +{ + /// + /// Custom dockable log window with advanced filtering capabilities. + /// Supports filtering by class, method, log level, time range, and text search. + /// Multiple instances can be opened with independent filter states. + /// + /// PERFORMANCE NOTE: For large log counts (10,000+), consider implementing virtualized scrolling. + /// Only render visible entries within the scroll view to avoid GUI overhead. + /// See DrawLogEntries() method for potential optimization location. + /// + public class CustomLogWindow : EditorWindow + { + #region Window State + + private Vector2 scrollPosition; + private readonly List allLogs = new List(); + + #endregion + + #region Filter State + + private HashSet activeClassTags = new HashSet(); + private HashSet activeMethodTags = new HashSet(); + private HashSet selectedClassFilters = new HashSet(); + private HashSet selectedMethodFilters = new HashSet(); + private HashSet selectedLevelFilters = new HashSet + { + LogLevel.Debug, LogLevel.Info, LogLevel.Warning, LogLevel.Error + }; + private string searchText = ""; + private bool autoScroll = true; + + // Time range filtering + private bool enableTimeFilter = false; + private float minTimestamp = 0; + private float maxTimestamp = 0; + private float currentMaxTimestamp = 0; + + #endregion + + #region UI State + + private bool showClassFilter = true; + private bool showMethodFilter = false; + private bool showLevelFilter = true; + private bool showTimeFilter = false; + + // Colors for log levels + private readonly Color debugColor = Color.white; + private readonly Color infoColor = Color.white; + private readonly Color warningColor = Color.yellow; + private readonly Color errorColor = new Color(1f, 0.3f, 0.3f); + + // Alternating row background colors + private readonly Color evenRowColor = new Color(0.22f, 0.22f, 0.22f); + private readonly Color oddRowColor = new Color(0.26f, 0.26f, 0.26f); + + #endregion + + #region Menu Items + + [MenuItem("AppleHills/Custom Log Console")] + public static void ShowWindow() + { + var window = GetWindow("Custom Log"); + window.Show(); + } + + #endregion + + #region Unity Lifecycle + + private void OnEnable() + { + // Subscribe to new log events + Logging.OnLogEntryAdded += OnLogAdded; + + // Load existing logs + allLogs.AddRange(Logging.GetRecentLogs()); + + // Build initial tag lists + RebuildTagLists(); + + // Initialize time range + UpdateTimeRange(); + } + + private void OnDisable() + { + Logging.OnLogEntryAdded -= OnLogAdded; + } + + private void OnLogAdded(LogEntry entry) + { + allLogs.Add(entry); + + // Update active tags + activeClassTags.Add(entry.ClassName); + activeMethodTags.Add(entry.MethodName); + + // Update time range + UpdateTimeRange(); + + if (autoScroll) + scrollPosition.y = float.MaxValue; + + Repaint(); // Redraw window + } + + #endregion + + #region GUI Drawing + + private void OnGUI() + { + DrawToolbar(); + DrawFilters(); + DrawLogEntries(); + } + + private void DrawToolbar() + { + EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); + + // Clear button + if (GUILayout.Button("Clear", EditorStyles.toolbarButton, GUILayout.Width(50))) + { + allLogs.Clear(); + activeClassTags.Clear(); + activeMethodTags.Clear(); + Logging.ClearLogs(); + Repaint(); + } + + // Auto-scroll toggle + autoScroll = GUILayout.Toggle(autoScroll, "Auto-scroll", EditorStyles.toolbarButton, GUILayout.Width(80)); + + GUILayout.FlexibleSpace(); + + // Log count + var filteredCount = GetFilteredLogs().Count(); + GUILayout.Label($"{filteredCount} / {allLogs.Count} logs", GUILayout.Width(100)); + + GUILayout.Space(10); + + // Export button + if (GUILayout.Button("Export", EditorStyles.toolbarButton, GUILayout.Width(60))) + { + ExportLogs(); + } + + GUILayout.Space(10); + + // Search box + GUILayout.Label("Search:", GUILayout.Width(50)); + searchText = GUILayout.TextField(searchText, EditorStyles.toolbarSearchField, GUILayout.Width(200)); + + EditorGUILayout.EndHorizontal(); + } + + private void DrawFilters() + { + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + // Class Filters + showClassFilter = EditorGUILayout.Foldout(showClassFilter, $"Class Filters ({selectedClassFilters.Count} selected)", true); + if (showClassFilter) + { + EditorGUI.indentLevel++; + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("All", GUILayout.Width(50))) + { + selectedClassFilters = new HashSet(activeClassTags); + } + if (GUILayout.Button("None", GUILayout.Width(50))) + { + selectedClassFilters.Clear(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginVertical(); + foreach (var tag in activeClassTags.OrderBy(t => t)) + { + bool isSelected = selectedClassFilters.Contains(tag); + bool newSelection = EditorGUILayout.ToggleLeft(tag, isSelected); + + if (newSelection && !isSelected) + selectedClassFilters.Add(tag); + else if (!newSelection && isSelected) + selectedClassFilters.Remove(tag); + } + EditorGUILayout.EndVertical(); + + EditorGUI.indentLevel--; + } + + EditorGUILayout.Space(5); + + // Method Filters + showMethodFilter = EditorGUILayout.Foldout(showMethodFilter, $"Method Filters ({selectedMethodFilters.Count} selected)", true); + if (showMethodFilter) + { + EditorGUI.indentLevel++; + + EditorGUILayout.BeginHorizontal(); + if (GUILayout.Button("All", GUILayout.Width(50))) + { + selectedMethodFilters = new HashSet(activeMethodTags); + } + if (GUILayout.Button("None", GUILayout.Width(50))) + { + selectedMethodFilters.Clear(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginVertical(); + foreach (var tag in activeMethodTags.OrderBy(t => t)) + { + bool isSelected = selectedMethodFilters.Contains(tag); + bool newSelection = EditorGUILayout.ToggleLeft(tag, isSelected); + + if (newSelection && !isSelected) + selectedMethodFilters.Add(tag); + else if (!newSelection && isSelected) + selectedMethodFilters.Remove(tag); + } + EditorGUILayout.EndVertical(); + + EditorGUI.indentLevel--; + } + + EditorGUILayout.Space(5); + + // Log Level Filters + showLevelFilter = EditorGUILayout.Foldout(showLevelFilter, "Log Level Filters", true); + if (showLevelFilter) + { + EditorGUI.indentLevel++; + foreach (LogLevel level in Enum.GetValues(typeof(LogLevel))) + { + bool isSelected = selectedLevelFilters.Contains(level); + bool newSelection = EditorGUILayout.ToggleLeft(level.ToString(), isSelected); + + if (newSelection && !isSelected) + selectedLevelFilters.Add(level); + else if (!newSelection && isSelected) + selectedLevelFilters.Remove(level); + } + EditorGUI.indentLevel--; + } + + EditorGUILayout.Space(5); + + // Time Range Filter + showTimeFilter = EditorGUILayout.Foldout(showTimeFilter, "Time Range Filter", true); + if (showTimeFilter) + { + EditorGUI.indentLevel++; + + enableTimeFilter = EditorGUILayout.Toggle("Enable Time Filter", enableTimeFilter); + + if (enableTimeFilter && currentMaxTimestamp > 0) + { + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField($"Min: {minTimestamp:F2}s", GUILayout.Width(100)); + EditorGUILayout.LabelField($"Max: {maxTimestamp:F2}s", GUILayout.Width(100)); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.MinMaxSlider( + ref minTimestamp, + ref maxTimestamp, + 0, + currentMaxTimestamp); + } + + EditorGUI.indentLevel--; + } + + EditorGUILayout.EndVertical(); + } + + private void DrawLogEntries() + { + // PERFORMANCE NOTE: For 10,000+ logs, consider virtualized scrolling here. + // Only render entries visible within the scroll view rect. + // Current implementation renders all filtered logs which may cause GUI slowdown. + + scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); + + var filteredLogs = GetFilteredLogs().ToList(); + + for (int i = 0; i < filteredLogs.Count; i++) + { + DrawLogEntry(filteredLogs[i], i); + } + + EditorGUILayout.EndScrollView(); + } + + private void DrawLogEntry(LogEntry entry, int index) + { + // Draw alternating row background + Color bgColor = (index % 2 == 0) ? evenRowColor : oddRowColor; + Rect rowRect = EditorGUILayout.BeginHorizontal(); + EditorGUI.DrawRect(rowRect, bgColor); + + // Color-code by log level + Color color = GetColorForLevel(entry.Level); + var originalColor = GUI.contentColor; + GUI.contentColor = color; + + // Timestamp + GUILayout.Label($"[{entry.Timestamp:F2}s]", GUILayout.Width(80)); + + // Level + GUILayout.Label($"[{entry.Level}]", GUILayout.Width(70)); + + // Class + GUILayout.Label($"[{entry.ClassName}]", GUILayout.Width(150)); + + // Method + GUILayout.Label($"[{entry.MethodName}]", GUILayout.Width(150)); + + // Message + GUILayout.Label(entry.Message, GUILayout.ExpandWidth(true)); + + EditorGUILayout.EndHorizontal(); + + GUI.contentColor = originalColor; + + // Right-click context menu + if (Event.current.type == EventType.ContextClick && rowRect.Contains(Event.current.mousePosition)) + { + GenericMenu menu = new GenericMenu(); + + menu.AddItem(new GUIContent("Copy Full Message"), false, () => + { + EditorGUIUtility.systemCopyBuffer = entry.FullFormattedMessage; + }); + + menu.AddItem(new GUIContent("Copy Message Only"), false, () => + { + EditorGUIUtility.systemCopyBuffer = entry.Message; + }); + + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("Filter to this Class"), false, () => + { + selectedClassFilters.Clear(); + selectedClassFilters.Add(entry.ClassName); + Repaint(); + }); + + menu.AddItem(new GUIContent("Filter to this Method"), false, () => + { + selectedMethodFilters.Clear(); + selectedMethodFilters.Add(entry.MethodName); + Repaint(); + }); + + menu.AddItem(new GUIContent("Filter to this Class + Method"), false, () => + { + selectedClassFilters.Clear(); + selectedClassFilters.Add(entry.ClassName); + selectedMethodFilters.Clear(); + selectedMethodFilters.Add(entry.MethodName); + Repaint(); + }); + + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("Filter to this Log Level"), false, () => + { + selectedLevelFilters.Clear(); + selectedLevelFilters.Add(entry.Level); + Repaint(); + }); + + menu.ShowAsContext(); + Event.current.Use(); + } + } + + #endregion + + #region Filtering Logic + + private IEnumerable GetFilteredLogs() + { + return allLogs.Where(entry => + { + // Filter by class (if any selected) + if (selectedClassFilters.Count > 0 && !selectedClassFilters.Contains(entry.ClassName)) + return false; + + // Filter by method (if any selected) + if (selectedMethodFilters.Count > 0 && !selectedMethodFilters.Contains(entry.MethodName)) + return false; + + // Filter by log level + if (!selectedLevelFilters.Contains(entry.Level)) + return false; + + // Filter by time range + if (enableTimeFilter) + { + if (entry.Timestamp < minTimestamp || entry.Timestamp > maxTimestamp) + return false; + } + + // Filter by search text + if (!string.IsNullOrEmpty(searchText)) + { + bool matchesSearch = + entry.ClassName.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + entry.MethodName.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + entry.Message.Contains(searchText, StringComparison.OrdinalIgnoreCase); + + if (!matchesSearch) + return false; + } + + return true; + }); + } + + #endregion + + #region Helper Methods + + private Color GetColorForLevel(LogLevel level) + { + return level switch + { + LogLevel.Debug => debugColor, + LogLevel.Info => infoColor, + LogLevel.Warning => warningColor, + LogLevel.Error => errorColor, + _ => infoColor + }; + } + + private void RebuildTagLists() + { + activeClassTags.Clear(); + activeMethodTags.Clear(); + + foreach (var log in allLogs) + { + activeClassTags.Add(log.ClassName); + activeMethodTags.Add(log.MethodName); + } + } + + private void UpdateTimeRange() + { + if (allLogs.Count > 0) + { + currentMaxTimestamp = allLogs.Max(l => l.Timestamp); + + // Initialize time range on first log + if (maxTimestamp == 0) + { + maxTimestamp = currentMaxTimestamp; + } + else + { + // Auto-expand max range as new logs come in + maxTimestamp = currentMaxTimestamp; + } + } + } + + private void ExportLogs() + { + string defaultFileName = $"logs_{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.txt"; + string path = EditorUtility.SaveFilePanel("Export Logs", "", defaultFileName, "txt"); + + if (!string.IsNullOrEmpty(path)) + { + try + { + var filteredLogs = GetFilteredLogs().ToList(); + var lines = filteredLogs.Select(e => e.FullFormattedMessage); + File.WriteAllLines(path, lines); + + EditorUtility.DisplayDialog("Export Successful", + $"Exported {filteredLogs.Count} log entries to:\n{path}", "OK"); + } + catch (Exception ex) + { + EditorUtility.DisplayDialog("Export Failed", + $"Failed to export logs:\n{ex.Message}", "OK"); + } + } + } + + #endregion + } +} + diff --git a/Assets/Editor/CustomLogWindow.cs.meta b/Assets/Editor/CustomLogWindow.cs.meta new file mode 100644 index 00000000..e024f9fa --- /dev/null +++ b/Assets/Editor/CustomLogWindow.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 60a300585fef4ac990c963c8b37c3887 +timeCreated: 1762814971 \ No newline at end of file diff --git a/Assets/Scripts/Core/Logging.cs b/Assets/Scripts/Core/Logging.cs index a0c0d387..d1f1c3ab 100644 --- a/Assets/Scripts/Core/Logging.cs +++ b/Assets/Scripts/Core/Logging.cs @@ -1,17 +1,144 @@ -namespace Core +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace Core { + /// + /// Centralized logging system with automatic class/method tagging and editor integration. + /// Broadcasts log entries to custom editor windows for filtering and analysis. + /// public static class Logging { - [System.Diagnostics.Conditional("ENABLE_LOG")] - public static void Debug(object message) + /// + /// Event fired when a new log entry is added. Subscribe to this in editor windows. + /// + public static event Action OnLogEntryAdded; + + // Store recent logs for late-subscriber editor windows (e.g., windows opened after play mode started) + private static readonly List RecentLogs = new List(); + private const int MaxStoredLogs = 5000; // Prevent memory bloat + + /// + /// Get all recent logs. Used by editor windows when they first open. + /// + public static IReadOnlyList GetRecentLogs() => RecentLogs; + + /// + /// Clear all stored logs. Useful for editor windows "Clear" button. + /// + public static void ClearLogs() { - UnityEngine.Debug.Log(message); + RecentLogs.Clear(); } [System.Diagnostics.Conditional("ENABLE_LOG")] - public static void Warning(object message) + public static void Debug(string message, + [CallerFilePath] string filePath = "", + [CallerMemberName] string memberName = "") { - UnityEngine.Debug.LogWarning(message); + LogInternal(LogLevel.Debug, message, filePath, memberName); + } + + [System.Diagnostics.Conditional("ENABLE_LOG")] + public static void Info(string message, + [CallerFilePath] string filePath = "", + [CallerMemberName] string memberName = "") + { + LogInternal(LogLevel.Info, message, filePath, memberName); + } + + [System.Diagnostics.Conditional("ENABLE_LOG")] + public static void Warning(string message, + [CallerFilePath] string filePath = "", + [CallerMemberName] string memberName = "") + { + LogInternal(LogLevel.Warning, message, filePath, memberName); + } + + public static void Error(string message, + [CallerFilePath] string filePath = "", + [CallerMemberName] string memberName = "") + { + LogInternal(LogLevel.Error, message, filePath, memberName); + } + + private static void LogInternal(LogLevel level, string message, string filePath, string memberName) + { + string className = Path.GetFileNameWithoutExtension(filePath); + string formattedMessage = $"[{className}][{memberName}] {message}"; + + // Create log entry + var entry = new LogEntry(className, memberName, message, level, Time.realtimeSinceStartup); + + // Store for late subscribers + RecentLogs.Add(entry); + if (RecentLogs.Count > MaxStoredLogs) + RecentLogs.RemoveAt(0); + + // Broadcast to editor windows (editor-only, won't fire in builds) + OnLogEntryAdded?.Invoke(entry); + + // Also log to Unity console + switch (level) + { + case LogLevel.Debug: + case LogLevel.Info: + UnityEngine.Debug.Log(formattedMessage); + break; + case LogLevel.Warning: + UnityEngine.Debug.LogWarning(formattedMessage); + break; + case LogLevel.Error: + UnityEngine.Debug.LogError(formattedMessage); + break; + } } } + + /// + /// Represents a single log entry with class, method, message, level, and timestamp. + /// + public class LogEntry + { + public string ClassName { get; } + public string MethodName { get; } + public string Message { get; } + public LogLevel Level { get; } + public float Timestamp { get; } + + public LogEntry(string className, string methodName, string message, LogLevel level, float timestamp) + { + ClassName = className; + MethodName = methodName; + Message = message; + Level = level; + Timestamp = timestamp; + } + + /// + /// Formatted message with class and method tags. + /// Format: [ClassName][MethodName] Message + /// + public string FormattedMessage => $"[{ClassName}][{MethodName}] {Message}"; + + /// + /// Full formatted message with timestamp and level. + /// Format: [12.34s][Debug][ClassName][MethodName] Message + /// + public string FullFormattedMessage => $"[{Timestamp:F2}s][{Level}]{FormattedMessage}"; + } + + /// + /// Log severity levels. + /// + public enum LogLevel + { + Debug, + Info, + Warning, + Error + } } \ No newline at end of file