// Copyright (c) 2012-2023 Wojciech Figat. All rights reserved. using System; using System.Collections.Generic; using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Xml; using FlaxEditor.GUI; using FlaxEditor.GUI.ContextMenu; using FlaxEditor.Options; using FlaxEngine; using FlaxEngine.Assertions; using FlaxEngine.GUI; using Object = FlaxEngine.Object; namespace FlaxEditor.Windows { /// /// Editor window used to show debug info, warning and error messages. Captures messages. /// /// public class DebugLogWindow : EditorWindow { /// /// Debug log entry description; /// public struct LogEntryDescription { /// /// The log level. /// public LogType Level; /// /// The message title. /// public string Title; /// /// The message description. /// public string Description; /// /// The target object hint id (don't store ref, object may be an actor that can be removed and restored later or sth). /// public Guid ContextObject; /// /// The location of the issue (file path). /// public string LocationFile; /// /// The location line number (zero or less to not use it); /// public int LocationLine; }; private enum LogGroup { Error = 0, Warning, Info, Max }; private class LogEntry : Control { private bool _isRightMouseDown; /// /// The default height of the entries. /// public const float DefaultHeight = 32.0f; private DebugLogWindow _window; public LogGroup Group; public LogEntryDescription Desc; public SpriteHandle Icon; public int LogCount = 1; public LogEntry(DebugLogWindow window, ref LogEntryDescription desc) : base(0, 0, 120, DefaultHeight) { AnchorPreset = AnchorPresets.HorizontalStretchTop; IsScrollable = true; AutoFocus = true; _window = window; Desc = desc; switch (desc.Level) { case LogType.Warning: Group = LogGroup.Warning; Icon = _window.IconWarning; break; case LogType.Info: Group = LogGroup.Info; Icon = _window.IconInfo; break; default: Group = LogGroup.Error; Icon = _window.IconError; break; } } /// /// Gets the information text. /// public string Info => Desc.Title + '\n' + Desc.Description; /// public override void Draw() { base.Draw(); // Cache data var style = Style.Current; var index = IndexInParent; var clientRect = new Rectangle(Float2.Zero, Size); // Background if (_window._selected == this) Render2D.FillRectangle(clientRect, IsFocused ? style.BackgroundSelected : style.LightBackground); else if (IsMouseOver) Render2D.FillRectangle(clientRect, style.BackgroundHighlighted); else if (index % 2 == 0) Render2D.FillRectangle(clientRect, style.Background * 0.9f); // Icon var iconColor = Group == LogGroup.Error ? Color.Red : (Group == LogGroup.Warning ? Color.Yellow : style.Foreground); Render2D.DrawSprite(Icon, new Rectangle(5, 0, 32, 32), iconColor); // Title var textRect = new Rectangle(38, 2, clientRect.Width - 40, clientRect.Height - 10); Render2D.PushClip(ref clientRect); if (LogCount == 1) { Render2D.DrawText(style.FontMedium, Desc.Title, textRect, style.Foreground); } else if (LogCount > 1) { Render2D.DrawText(style.FontMedium, $"{Desc.Title} ({LogCount})", textRect, style.Foreground); } Render2D.PopClip(); } /// public override void OnGotFocus() { base.OnGotFocus(); _window.Selected = this; } /// protected override void OnVisibleChanged() { // Deselect on hide if (!Visible && _window.Selected == this) _window.Selected = null; base.OnVisibleChanged(); } /// public override bool OnKeyDown(KeyboardKeys key) { // Up if (key == KeyboardKeys.ArrowUp) { int index = IndexInParent - 1; if (index >= 0) { var target = Parent.GetChild(index); target.Focus(); ((Panel)Parent.Parent).ScrollViewTo(target); return true; } } // Down else if (key == KeyboardKeys.ArrowDown) { int index = IndexInParent + 1; if (index < Parent.ChildrenCount) { var target = Parent.GetChild(index); target.Focus(); ((Panel)Parent.Parent).ScrollViewTo(target); return true; } } // Enter else if (key == KeyboardKeys.Return) { Open(); } // Ctrl+C else if (key == KeyboardKeys.C && Root.GetKey(KeyboardKeys.Control)) { Copy(); return true; } return base.OnKeyDown(key); } /// /// Opens the entry location. /// public void Open() { if (!string.IsNullOrEmpty(Desc.LocationFile) && File.Exists(Desc.LocationFile)) { Editor.Instance.CodeEditing.OpenFile(Desc.LocationFile, Desc.LocationLine); } } /// /// Copies the entry information to the system clipboard (as text). /// public void Copy() { Clipboard.Text = Info.Replace("\r\n", "\n").Replace("\n", Environment.NewLine); } public override bool OnMouseDoubleClick(Float2 location, MouseButton button) { Open(); return true; } /// public override bool OnMouseDown(Float2 location, MouseButton button) { if (base.OnMouseDown(location, button)) return true; if (button == MouseButton.Left) { Focus(); return true; } if (button == MouseButton.Right) { Focus(); _isRightMouseDown = true; return true; } return false; } /// public override bool OnMouseUp(Float2 location, MouseButton button) { if (base.OnMouseUp(location, button)) return true; if (_isRightMouseDown && button == MouseButton.Right) { Focus(); var menu = new ContextMenu(); menu.AddButton("Copy", Copy); menu.AddButton("Open", Open).Enabled = !string.IsNullOrEmpty(Desc.LocationFile) && File.Exists(Desc.LocationFile); menu.Show(this, location); } return false; } /// public override void OnMouseLeave() { _isRightMouseDown = false; base.OnMouseLeave(); } } private readonly SplitPanel _split; private readonly Label _logInfo; private readonly VerticalPanel _entriesPanel; private LogEntry _selected; private readonly int[] _logCountPerGroup = new int[(int)LogGroup.Max]; private readonly Regex _logRegex = new Regex("at (.*) in (.*):(line (\\d*)|(\\d*))"); private readonly ThreadLocal _stringBuilder = new ThreadLocal(() => new StringBuilder(), false); private InterfaceOptions.TimestampsFormats _timestampsFormats; private readonly object _locker = new object(); private readonly List _pendingEntries = new List(32); private readonly ToolStripButton _clearOnPlayButton; private readonly ToolStripButton _collapseLogsButton; private readonly ToolStripButton _pauseOnErrorButton; private readonly ToolStripButton[] _groupButtons = new ToolStripButton[3]; private LogType _iconType = LogType.Info; internal SpriteHandle IconInfo; internal SpriteHandle IconWarning; internal SpriteHandle IconError; /// /// Initializes a new instance of the class. /// /// The editor. public DebugLogWindow(Editor editor) : base(editor, true, ScrollBars.None) { Title = "Debug Log"; Icon = IconInfo; OnEditorOptionsChanged(Editor.Options.Options); // Toolstrip var toolstrip = new ToolStrip(22.0f) { Parent = this, }; toolstrip.AddButton("Clear", Clear).LinkTooltip("Clears all log entries"); _clearOnPlayButton = (ToolStripButton)toolstrip.AddButton("Clear on Play").SetAutoCheck(true).SetChecked(true).LinkTooltip("Clears all log entries on enter playmode"); _collapseLogsButton = (ToolStripButton)toolstrip.AddButton("Collapse").SetAutoCheck(true).SetChecked(true).LinkTooltip("Collapses similar logs."); _pauseOnErrorButton = (ToolStripButton)toolstrip.AddButton("Pause on Error").SetAutoCheck(true).LinkTooltip("Performs auto pause on error"); toolstrip.AddSeparator(); _groupButtons[0] = (ToolStripButton)toolstrip.AddButton(editor.Icons.Error32, () => UpdateLogTypeVisibility(LogGroup.Error, _groupButtons[0].Checked)).SetAutoCheck(true).SetChecked(true).LinkTooltip("Shows/hides error messages"); _groupButtons[1] = (ToolStripButton)toolstrip.AddButton(editor.Icons.Warning32, () => UpdateLogTypeVisibility(LogGroup.Warning, _groupButtons[1].Checked)).SetAutoCheck(true).SetChecked(true).LinkTooltip("Shows/hides warning messages"); _groupButtons[2] = (ToolStripButton)toolstrip.AddButton(editor.Icons.Info32, () => UpdateLogTypeVisibility(LogGroup.Info, _groupButtons[2].Checked)).SetAutoCheck(true).SetChecked(true).LinkTooltip("Shows/hides info messages"); UpdateCount(); // Split panel _split = new SplitPanel(Orientation.Vertical, ScrollBars.Vertical, ScrollBars.Both) { AnchorPreset = AnchorPresets.StretchAll, Offsets = new Margin(0, 0, toolstrip.Bottom, 0), SplitterValue = 0.8f, Parent = this }; // Info detail info _logInfo = new Label { Parent = _split.Panel2, AutoWidth = true, AutoHeight = true, Margin = new Margin(4), VerticalAlignment = TextAlignment.Near, HorizontalAlignment = TextAlignment.Near, Offsets = Margin.Zero, }; // Entries panel _entriesPanel = new VerticalPanel { AnchorPreset = AnchorPresets.HorizontalStretchTop, Offsets = Margin.Zero, IsScrollable = true, Parent = _split.Panel1, }; // Cache entries icons IconInfo = Editor.Icons.Info64; IconWarning = Editor.Icons.Warning64; IconError = Editor.Icons.Error64; // Bind events Editor.Options.OptionsChanged += OnEditorOptionsChanged; Debug.Logger.LogHandler.SendLog += LogHandlerOnSendLog; Debug.Logger.LogHandler.SendExceptionLog += LogHandlerOnSendExceptionLog; } private void OnEditorOptionsChanged(EditorOptions options) { _timestampsFormats = options.Interface.DebugLogTimestampsFormat; } /// /// Clears the log. /// public void Clear() { if (_entriesPanel == null) return; RemoveEntries(); } /// /// Adds the specified log entry. /// /// The log entry description. public void Add(ref LogEntryDescription desc) { if (_entriesPanel == null) return; // Create new entry switch (_timestampsFormats) { case InterfaceOptions.TimestampsFormats.Utc: desc.Title = $"[{DateTime.UtcNow}] {desc.Title}"; break; case InterfaceOptions.TimestampsFormats.LocalTime: desc.Title = $"[{DateTime.Now}] {desc.Title}"; break; case InterfaceOptions.TimestampsFormats.TimeSinceStartup: desc.Title = string.Format("[{0:g}] ", TimeSpan.FromSeconds(Time.TimeSinceStartup)) + desc.Title; break; } var newEntry = new LogEntry(this, ref desc); // Enqueue lock (_locker) { _pendingEntries.Add(newEntry); } if (newEntry.Group == LogGroup.Warning && _iconType < LogType.Warning) { _iconType = LogType.Warning; UpdateIcon(); } if (newEntry.Group == LogGroup.Error && _iconType < LogType.Error) { _iconType = LogType.Error; UpdateIcon(); } // Pause on Error (we should do it as fast as possible) if (newEntry.Group == LogGroup.Error && _pauseOnErrorButton.Checked && Editor.StateMachine.CurrentState == Editor.StateMachine.PlayingState) { Editor.Simulation.RequestPausePlay(); } } /// /// Gets or sets the selected entry. /// private LogEntry Selected { get => _selected; set { // Check if value will change if (_selected != value) { // Select _selected = value; _logInfo.Text = _selected?.Info ?? string.Empty; } } } private void UpdateLogTypeVisibility(LogGroup group, bool isVisible) { _entriesPanel.IsLayoutLocked = true; var children = _entriesPanel.Children; for (int i = 0; i < children.Count; i++) { if (children[i] is LogEntry logEntry && logEntry.Group == group) logEntry.Visible = isVisible; } _entriesPanel.IsLayoutLocked = false; _entriesPanel.PerformLayout(); } private void UpdateCount() { UpdateCount((int)LogGroup.Error, " Error"); UpdateCount((int)LogGroup.Warning, " Warning"); UpdateCount((int)LogGroup.Info, " Message"); if (_logCountPerGroup[(int)LogGroup.Error] == 0) { _iconType = _logCountPerGroup[(int)LogGroup.Warning] == 0 ? LogType.Info : LogType.Warning; UpdateIcon(); } } private void UpdateCount(int group, string msg) { if (_logCountPerGroup[group] != 1) msg += 's'; _groupButtons[group].Text = _logCountPerGroup[group] + msg; } private void UpdateIcon() { if (_iconType == LogType.Warning) { Icon = Editor.Icons.Warning32; } else if (_iconType == LogType.Error) { Icon = Editor.Icons.Error32; } else { Icon = Editor.Icons.Info32; } } private void LogHandlerOnSendLog(LogType level, string msg, Object o, string stackTrace) { var desc = new LogEntryDescription { Level = level, Title = msg, ContextObject = o?.ID ?? Guid.Empty, }; if (!string.IsNullOrEmpty(stackTrace)) { // Detect code location and remove leading internal stack trace part var matches = _logRegex.Matches(stackTrace); bool foundStart = false, noLocation = true; var fineStackTrace = _stringBuilder.Value; fineStackTrace.Clear(); fineStackTrace.Capacity = Mathf.Max(fineStackTrace.Capacity, stackTrace.Length); for (int i = 0; i < matches.Count; i++) { var match = matches[i]; var matchLocation = match.Groups[1].Value.Trim(); if (matchLocation.StartsWith("FlaxEngine.Debug.", StringComparison.Ordinal)) { // C# start foundStart = true; } else if (matchLocation.StartsWith("DebugLog::", StringComparison.Ordinal)) { // C++ start foundStart = true; } else if (foundStart) { if (noLocation) { desc.LocationFile = match.Groups[2].Value; int.TryParse(match.Groups[5].Value, out desc.LocationLine); noLocation = false; } fineStackTrace.AppendLine(match.Groups[0].Value); } } desc.Description = fineStackTrace.ToString(); } Add(ref desc); } private void LogHandlerOnSendExceptionLog(Exception exception, Object o) { LogEntryDescription desc = new LogEntryDescription { Level = LogType.Error, Title = exception.Message, Description = exception.StackTrace, ContextObject = o?.ID ?? Guid.Empty, }; // Detect code location if (!string.IsNullOrEmpty(exception.StackTrace)) { var match = _logRegex.Match(exception.StackTrace); if (match.Success) { desc.LocationFile = match.Groups[2].Value; int.TryParse(match.Groups[3].Value, out desc.LocationLine); } } Add(ref desc); } private void RemoveEntries() { _entriesPanel.IsLayoutLocked = true; Selected = null; for (int i = 0; i < _entriesPanel.ChildrenCount; i++) { if (_entriesPanel.GetChild(i) is LogEntry entry) { _logCountPerGroup[(int)entry.Group]--; entry.Dispose(); i--; } } UpdateCount(); _entriesPanel.IsLayoutLocked = false; _entriesPanel.PerformLayout(); } /// public override void Update(float deltaTime) { lock (_locker) { if (_pendingEntries.Count > 0) { // Check if user want's to scroll view by var (or is viewing earlier entry) var panelScroll = (Panel)_entriesPanel.Parent; bool scrollView = (panelScroll.VScrollBar.Maximum - panelScroll.VScrollBar.TargetValue) < LogEntry.DefaultHeight * 1.5f; // Add pending entries LogEntry newEntry = null; bool anyVisible = false; _entriesPanel.IsLayoutLocked = true; var spacing = _entriesPanel.Spacing; var margin = _entriesPanel.Margin; var offset = _entriesPanel.Offset; var width = _entriesPanel.Width - margin.Width; var top = _entriesPanel.Children.Count != 0 ? _entriesPanel.Children[_entriesPanel.Children.Count - 1].Bottom + spacing : margin.Top; for (int i = 0; i < _pendingEntries.Count; i++) { if (_collapseLogsButton.Checked) { bool logExists = false; foreach (var child in _entriesPanel.Children) { if (child is LogEntry entry) { var pendingEntry = _pendingEntries[i]; if (string.Equals(entry.Desc.Title, pendingEntry.Desc.Title, StringComparison.Ordinal) && string.Equals(entry.Desc.LocationFile, pendingEntry.Desc.LocationFile, StringComparison.Ordinal) && entry.Desc.Level == pendingEntry.Desc.Level && string.Equals(entry.Desc.Description, pendingEntry.Desc.Description, StringComparison.Ordinal) && entry.Desc.LocationLine == pendingEntry.Desc.LocationLine) { entry.LogCount += 1; newEntry = entry; logExists = true; break; } } } if (logExists) continue; } newEntry = _pendingEntries[i]; newEntry.Visible = _groupButtons[(int)newEntry.Group].Checked; anyVisible |= newEntry.Visible; newEntry.Parent = _entriesPanel; newEntry.Bounds = new Rectangle(margin.Left + offset.X, top + offset.Y, width, newEntry.Height); top = newEntry.Bottom + spacing; _logCountPerGroup[(int)newEntry.Group]++; } _entriesPanel.Height = top + margin.Bottom; _entriesPanel.IsLayoutLocked = false; _pendingEntries.Clear(); UpdateCount(); Assert.IsNotNull(newEntry); // Scroll to the new entry (if any added to view) if (scrollView && anyVisible) panelScroll.ScrollViewTo(newEntry); } } base.Update(deltaTime); } /// public override void OnPlayBegin() { // Clear on Play if (_clearOnPlayButton.Checked) { Clear(); } } /// public override void OnStartContainsFocus() { _iconType = LogType.Info; UpdateIcon(); base.OnStartContainsFocus(); } /// public override void OnDestroy() { if (IsDisposing) return; // Unbind events Editor.Options.OptionsChanged -= OnEditorOptionsChanged; Debug.Logger.LogHandler.SendLog -= LogHandlerOnSendLog; Debug.Logger.LogHandler.SendExceptionLog -= LogHandlerOnSendExceptionLog; base.OnDestroy(); } /// public override bool UseLayoutData => true; /// public override void OnLayoutSerialize(XmlWriter writer) { LayoutSerializeSplitter(writer, "Split", _split); } /// public override void OnLayoutDeserialize(XmlElement node) { LayoutDeserializeSplitter(node, "Split", _split); } /// public override void OnLayoutDeserialize() { _split.SplitterValue = 0.8f; } } }