using System; using System.Collections.Generic; using UnityEngine; #if ENABLE_INPUT_SYSTEM using UnityEngine.InputSystem; #endif #if UNITY_EDITOR using UnityEditor; #endif namespace Consolation { /// /// A console to display Unity's debug logs in-game. /// /// Version: 1.4.1 /// public class Console : MonoBehaviour { #region Inspector Settings [Tooltip("Hotkey to show and hide the console.")] #if ENABLE_INPUT_SYSTEM public Key toggleKey = Key.Backquote; #else public KeyCode toggleKey = KeyCode.BackQuote; #endif [Tooltip("Whether to open as soon as the game starts.")] public bool openOnStart; [Tooltip("Whether to open the window by shaking the device (mobile-only).")] public bool shakeToOpen = true; [Tooltip("Whether to require touches while shaking to avoid accidental shakes.")] public bool shakeRequiresTouch; [Tooltip("Acceleration (squared) above which to open the console.")] public float shakeAcceleration = 3f; [Tooltip("Number of seconds that need to pass between visibility toggles. This threshold prevents closing again while shaking to open.")] public float toggleThresholdSeconds = .5f; [Tooltip("Whether to keep a limited number of logs. Useful if memory usage is a concern.")] public bool restrictLogCount; [Tooltip("Number of logs to keep before removing old ones.")] public int maxLogCount = 1000; [Tooltip("Whether log messages are collapsed by default or not.")] public bool collapseLogOnStart; [Tooltip("Font size to display log entries with.")] public int logFontSize = 12; [Tooltip("Amount to scale UI by.")] public float scaleFactor = 1f; [Tooltip("Custom styles to apply to window.")] public GUISkin skin; #endregion static readonly GUIContent clearLabel = new GUIContent("Clear", "Clear contents of console."); static readonly GUIContent onlyLastLogLabel = new GUIContent("Only Last Log", "Show only most recent log."); static readonly GUIContent collapseLabel = new GUIContent("Collapse", "Hide repeated messages."); static GUIStyle dividerStyle; const int margin = 20; const string windowTitle = "Console"; static readonly Dictionary logTypeColors = new Dictionary { { LogType.Assert, Color.white }, { LogType.Error, Color.red }, { LogType.Exception, Color.red }, { LogType.Log, Color.white }, { LogType.Warning, Color.yellow }, }; bool isCollapsed; bool isOnlyLastLogVisible; bool isVisible; float lastToggleTime; readonly List logs = new List(); Vector2 logsScrollPosition; readonly ConcurrentQueue queuedLogs = new ConcurrentQueue(); int? selectedLogIndex; Vector2 stackTraceScrollPosition; readonly Rect titleBarRect = new Rect(0, 0, 10000, 20); Rect windowRect = new Rect(margin, margin, 0, 0); readonly Dictionary logTypeFilters = new Dictionary { { LogType.Assert, true }, { LogType.Error, true }, { LogType.Exception, true }, { LogType.Log, true }, { LogType.Warning, true }, }; #region MonoBehaviour Messages void OnDisable() { Application.logMessageReceivedThreaded -= HandleLogThreaded; } void OnEnable() { Application.logMessageReceivedThreaded += HandleLogThreaded; } void OnGUI() { if (!isVisible) { return; } var previousGUISkin = GUI.skin; if (skin != null) { GUI.skin = skin; } GUI.matrix = Matrix4x4.Scale(Vector3.one * scaleFactor); windowRect.width = (Screen.width / scaleFactor) - (margin * 2); windowRect.height = (Screen.height / scaleFactor) - (margin * 2); windowRect = GUILayout.Window(123456, windowRect, DrawWindow, windowTitle); GUI.skin = previousGUISkin; } void Start() { if (collapseLogOnStart) { isCollapsed = true; } if (openOnStart) { isVisible = true; } if (shakeRequiresTouch) { EnableMultiTouch(); } // Unity complains if we define this style at the point of declaration. dividerStyle = new GUIStyle { fixedHeight = 1, margin = new RectOffset(0, 0, 4, 4), normal = { background = Texture2D.whiteTexture }, }; } void Update() { UpdateQueuedLogs(); if (WasToggleKeyPressed()) { isVisible = !isVisible; } if (shakeToOpen && Time.realtimeSinceStartup - lastToggleTime >= toggleThresholdSeconds && WasShaken() && (!shakeRequiresTouch || WasMultiTouchThresholdExceeded())) { isVisible = !isVisible; lastToggleTime = Time.realtimeSinceStartup; } } #endregion void DrawLog(int logIndex, GUIStyle logStyle) { var log = logs[logIndex]; GUI.contentColor = logTypeColors[log.Type]; if (isCollapsed) { // Draw collapsed log with badge indicating count. GUILayout.BeginHorizontal(); { GUILayout.Label(log.Message, logStyle); GUILayout.FlexibleSpace(); GUILayout.Label(log.Count.ToString(), GUI.skin.box); } GUILayout.EndHorizontal(); DrawLogSelectButton(logIndex); } else { var labelCount = isOnlyLastLogVisible ? 1 : log.Count; for (var i = 0; i < labelCount; i += 1) { GUILayout.Label(log.Message, logStyle); DrawLogSelectButton(logIndex); } } GUI.contentColor = Color.white; } void DrawLogList() { var logStyle = GUI.skin.label; logStyle.fontSize = logFontSize; logsScrollPosition = GUILayout.BeginScrollView(logsScrollPosition); // Used to determine height of accumulated log labels. GUILayout.BeginVertical(); { if (isOnlyLastLogVisible) { var lastVisibleLogIndex = GetLastVisibleLogIndex(); if (lastVisibleLogIndex.HasValue) { DrawLog(lastVisibleLogIndex.Value, logStyle); } } else { for (var logIndex = 0; logIndex < logs.Count; logIndex++) { if (!IsLogVisible(logIndex)) { continue; } DrawLog(logIndex, logStyle); } } } GUILayout.EndVertical(); var innerScrollRect = GUILayoutUtility.GetLastRect(); GUILayout.EndScrollView(); var outerScrollRect = GUILayoutUtility.GetLastRect(); // If we're scrolled to bottom now, guarantee that it continues to be in next cycle. if (Event.current.type == EventType.Repaint && IsScrolledToBottom(innerScrollRect, outerScrollRect)) { ScrollToBottom(); } } void DrawLogSelectButton(int logIndex) { var lastRect = GUILayoutUtility.GetLastRect(); if (GUI.Button(lastRect, GUIContent.none, GUIStyle.none)) { selectedLogIndex = logIndex; stackTraceScrollPosition = Vector2.zero; } } void DrawStackTrace() { if (!selectedLogIndex.HasValue) { return; } GUILayout.Box(GUIContent.none, dividerStyle); // GUILayout shrinks the stack trace scroll view every time a new log gets added. // We seem to need to set a fixed height to work around this. var scrollViewHeight = windowRect.height / 2; stackTraceScrollPosition = GUILayout.BeginScrollView(stackTraceScrollPosition, GUILayout.Height(scrollViewHeight)); { var selectedLog = logs[selectedLogIndex.Value]; GUILayout.Label(selectedLog.Message); GUILayout.Label(selectedLog.StackTrace); } GUILayout.EndScrollView(); if (GUILayout.Button("Hide Stack Trace", GUILayout.ExpandWidth(false))) { selectedLogIndex = null; } } void DrawToolbar() { GUILayout.BeginHorizontal(); { if (GUILayout.Button(clearLabel)) { logs.Clear(); selectedLogIndex = null; } foreach (LogType logType in Enum.GetValues(typeof(LogType))) { var currentState = logTypeFilters[logType]; var label = logType.ToString(); logTypeFilters[logType] = GUILayout.Toggle(currentState, label, GUILayout.ExpandWidth(false)); GUILayout.Space(20); } isCollapsed = GUILayout.Toggle(isCollapsed, collapseLabel, GUILayout.ExpandWidth(false)); isOnlyLastLogVisible = GUILayout.Toggle(isOnlyLastLogVisible, onlyLastLogLabel, GUILayout.ExpandWidth(false)); } GUILayout.EndHorizontal(); } void DrawWindow(int windowID) { DrawLogList(); DrawStackTrace(); DrawToolbar(); // Allow the window to be dragged by its title bar. GUI.DragWindow(titleBarRect); } void UpdateQueuedLogs() { while (queuedLogs.TryDequeue(out var log)) { ProcessLogItem(log); } } int? GetLastVisibleLogIndex() { for (var logIndex = logs.Count - 1; logIndex >= 0; logIndex--) { if (IsLogVisible(logIndex)) { return logIndex; } } return null; } void HandleLogThreaded(string message, string stackTrace, LogType type) { // Queue the log into a ConcurrentQueue to be processed later in the Unity main thread, // so that we don't get GUI-related errors for logs coming from other threads var log = new Log(message, stackTrace, type); queuedLogs.Enqueue(log); } void ProcessLogItem(Log log) { var lastLog = logs.Count > 0 ? logs[logs.Count - 1] : (Log?)null; var isDuplicateOfLastLog = lastLog.HasValue && log.Equals(lastLog.Value); if (isDuplicateOfLastLog) { // Replace previous log with incremented count instead of adding a new one. logs[logs.Count - 1] = lastLog.Value.IncrementedCount(); } else { logs.Add(log); TrimExcessLogs(); } } bool IsLogVisible(int logIndex) { var logType = logs[logIndex].Type; return logTypeFilters[logType]; } bool IsScrolledToBottom(Rect innerScrollRect, Rect outerScrollRect) { var innerScrollHeight = innerScrollRect.height; // Take into account extra padding added to the scroll container. var outerScrollHeight = outerScrollRect.height - GUI.skin.box.padding.vertical; // If contents of scroll view haven't exceeded outer container, treat it as scrolled to bottom. if (outerScrollHeight > innerScrollHeight) { return true; } // Scrolled to bottom (with error margin for float math) return Mathf.Approximately(innerScrollHeight, logsScrollPosition.y + outerScrollHeight); } void ScrollToBottom() { logsScrollPosition = new Vector2(0, int.MaxValue); } void TrimExcessLogs() { if (!restrictLogCount) { return; } var amountToRemove = logs.Count - maxLogCount; if (amountToRemove <= 0) { return; } logs.RemoveRange(0, amountToRemove); } bool WasMultiTouchThresholdExceeded() { #if ENABLE_INPUT_SYSTEM var touchCount = UnityEngine.InputSystem.EnhancedTouch.Touch.activeTouches.Count; #else var touchCount = Input.touchCount; #endif return touchCount > 2; } bool WasShaken() { #if ENABLE_INPUT_SYSTEM var acceleration = Accelerometer.current?.acceleration.ReadValue() ?? Vector3.zero; #else var acceleration = Input.acceleration; #endif return acceleration.sqrMagnitude > shakeAcceleration; } bool WasToggleKeyPressed() { #if ENABLE_INPUT_SYSTEM return Keyboard.current[toggleKey].wasPressedThisFrame; #else return Input.GetKeyDown(toggleKey); #endif } static void EnableMultiTouch() { #if ENABLE_INPUT_SYSTEM UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport.Enable(); #else Input.multiTouchEnabled = true; #endif } } /// /// A basic container for log details. /// readonly struct Log { public readonly int Count; public readonly string Message; public readonly string StackTrace; public readonly LogType Type; public Log(string message, string stackTrace, LogType type) { Count = 1; Message = TruncateForGUILabel(message); StackTrace = TruncateForGUILabel(stackTrace); Type = type; } Log(string message, string stackTrace, LogType type, int count) { Count = count; Message = message; StackTrace = stackTrace; Type = type; } public bool Equals(Log log) { return Message == log.Message && StackTrace == log.StackTrace && Type == log.Type; } public Log IncrementedCount() { return new Log(Message, StackTrace, Type, Count + 1); } /// /// Returns text shortened to fit in a GUILayout.Label. /// static string TruncateForGUILabel(string text) { // The max string length supported by UnityEngine.GUILayout.Label without triggering this error: // "String too long for TextMeshGenerator. Cutting off characters." const int maxLabelLength = 16382; return string.IsNullOrEmpty(text) || text.Length <= maxLabelLength ? text : text.Substring(0, maxLabelLength); } } /// /// Alternative to System.Collections.Concurrent.ConcurrentQueue /// (It's only available in .NET 4.0 and greater) /// /// /// It's a bit slow (as it uses locks), and only provides a small subset of the interface /// Overall, the implementation is intended to be simple & robust /// class ConcurrentQueue { readonly Queue queue = new Queue(); readonly object queueLock = new object(); public void Enqueue(T item) { lock (queueLock) { queue.Enqueue(item); } } public bool TryDequeue(out T result) { lock (queueLock) { if (queue.Count == 0) { result = default(T); return false; } result = queue.Dequeue(); return true; } } } #if UNITY_EDITOR [CustomEditor(typeof(Console))] class ConsoleEditor : Editor { SerializedProperty toggleKey; SerializedProperty openOnStart; SerializedProperty shakeToOpen; SerializedProperty shakeRequiresTouch; SerializedProperty shakeAcceleration; SerializedProperty toggleThresholdSeconds; SerializedProperty restrictLogCount; SerializedProperty maxLogCount; SerializedProperty collapseLogOnStart; SerializedProperty logFontSize; SerializedProperty scaleFactor; SerializedProperty skin; void OnEnable() { toggleKey = serializedObject.FindProperty("toggleKey"); openOnStart = serializedObject.FindProperty("openOnStart"); shakeToOpen = serializedObject.FindProperty("shakeToOpen"); shakeRequiresTouch = serializedObject.FindProperty("shakeRequiresTouch"); shakeAcceleration = serializedObject.FindProperty("shakeAcceleration"); toggleThresholdSeconds = serializedObject.FindProperty("toggleThresholdSeconds"); restrictLogCount = serializedObject.FindProperty("restrictLogCount"); maxLogCount = serializedObject.FindProperty("maxLogCount"); collapseLogOnStart = serializedObject.FindProperty("collapseLogOnStart"); logFontSize = serializedObject.FindProperty("logFontSize"); scaleFactor = serializedObject.FindProperty("scaleFactor"); skin = serializedObject.FindProperty("skin"); } public override void OnInspectorGUI() { EditorGUILayout.PropertyField(toggleKey); EditorGUILayout.PropertyField(openOnStart); EditorGUILayout.PropertyField(shakeToOpen); using (new EditorGUI.DisabledScope(!shakeToOpen.boolValue)) using (new EditorGUI.IndentLevelScope()) { EditorGUILayout.PropertyField(shakeRequiresTouch); EditorGUILayout.PropertyField(shakeAcceleration); } EditorGUILayout.PropertyField(toggleThresholdSeconds); EditorGUILayout.PropertyField(restrictLogCount); using (new EditorGUI.DisabledScope(!restrictLogCount.boolValue)) using (new EditorGUI.IndentLevelScope()) { EditorGUILayout.PropertyField(maxLogCount); } EditorGUILayout.PropertyField(collapseLogOnStart); EditorGUILayout.Space(); GUILayout.Label("Style", EditorStyles.boldLabel); EditorGUILayout.PropertyField(logFontSize); EditorGUILayout.PropertyField(scaleFactor); EditorGUILayout.PropertyField(skin); serializedObject.ApplyModifiedProperties(); } } #endif }