diff --git a/Dalamud/Configuration/Internal/DalamudConfiguration.cs b/Dalamud/Configuration/Internal/DalamudConfiguration.cs index 957be12b9..85a9507c9 100644 --- a/Dalamud/Configuration/Internal/DalamudConfiguration.cs +++ b/Dalamud/Configuration/Internal/DalamudConfiguration.cs @@ -215,6 +215,11 @@ internal sealed class DalamudConfiguration : IServiceType, IDisposable /// public bool LogOpenAtStartup { get; set; } + /// + /// Gets or sets the number of lines to keep for the Dalamud Console window. + /// + public int LogLinesLimit { get; set; } = 10000; + /// /// Gets or sets a value indicating whether or not the dev bar should open at startup. /// diff --git a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs index bf559c4d7..f36d79222 100644 --- a/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs +++ b/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs @@ -6,6 +6,7 @@ using System.Numerics; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; +using System.Threading; using Dalamud.Configuration.Internal; using Dalamud.Game.Command; @@ -28,7 +29,11 @@ namespace Dalamud.Interface.Internal.Windows; /// internal class ConsoleWindow : Window, IDisposable { - private readonly List logText = new(); + private const int LogLinesMinimum = 100; + private const int LogLinesMaximum = 1000000; + + private readonly RollingList logText; + private volatile int newRolledLines; private readonly object renderLock = new(); private readonly List history = new(); @@ -42,12 +47,14 @@ internal class ConsoleWindow : Window, IDisposable private string pluginFilter = string.Empty; private bool filterShowUncaughtExceptions; + private bool settingsPopupWasOpen; private bool showFilterToolbar; private bool clearLog; private bool copyLog; private bool copyMode; private bool killGameArmed; private bool autoScroll; + private int logLinesLimit; private bool autoOpen; private bool regexError; @@ -74,9 +81,17 @@ internal class ConsoleWindow : Window, IDisposable }; this.RespectCloseHotkey = false; + + this.logLinesLimit = configuration.LogLinesLimit; + + var limit = Math.Max(LogLinesMinimum, this.logLinesLimit); + this.logText = new(limit); + this.FilteredLogEntries = new(limit); + + configuration.DalamudConfigurationSaved += this.OnDalamudConfigurationSaved; } - private List FilteredLogEntries { get; set; } = new(); + private RollingList FilteredLogEntries { get; set; } /// public override void OnOpen() @@ -91,6 +106,7 @@ internal class ConsoleWindow : Window, IDisposable public void Dispose() { SerilogEventSink.Instance.LogLine -= this.OnLogLine; + Service.Get().DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved; } /// @@ -180,6 +196,9 @@ internal class ConsoleWindow : Window, IDisposable var dividerOffset = ImGui.CalcTextSize("00:00:00.000 | AAA ").X + (ImGui.CalcTextSize(" ").X / 2); var cursorLogLine = ImGui.CalcTextSize("00:00:00.000 | AAA | ").X; + var lastLinePosY = 0.0f; + var logLineHeight = 0.0f; + lock (this.renderLock) { clipper.Begin(this.FilteredLogEntries.Count); @@ -187,7 +206,8 @@ internal class ConsoleWindow : Window, IDisposable { for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) { - var line = this.FilteredLogEntries[i]; + var index = Math.Max(i - this.newRolledLines, 0); // Prevents flicker effect. Also workaround to avoid negative indexes. + var line = this.FilteredLogEntries[index]; if (!line.IsMultiline && !this.copyLog) ImGui.Separator(); @@ -228,6 +248,10 @@ internal class ConsoleWindow : Window, IDisposable ImGui.SetCursorPosX(cursorLogLine); ImGui.TextUnformatted(line.Line); + + var currentLinePosY = ImGui.GetCursorPosY(); + logLineHeight = currentLinePosY - lastLinePosY; + lastLinePosY = currentLinePosY; } } @@ -239,6 +263,12 @@ internal class ConsoleWindow : Window, IDisposable ImGui.PopStyleVar(); + var newRolledLinesCount = Interlocked.Exchange(ref this.newRolledLines, 0); + if (!this.autoScroll || ImGui.GetScrollY() < ImGui.GetScrollMaxY()) + { + ImGui.SetScrollY(ImGui.GetScrollY() - (logLineHeight * newRolledLinesCount)); + } + if (this.autoScroll && ImGui.GetScrollY() >= ImGui.GetScrollMaxY()) { ImGui.SetScrollHereY(1.0f); @@ -363,21 +393,21 @@ internal class ConsoleWindow : Window, IDisposable ImGui.SameLine(); - this.autoScroll = configuration.LogAutoScroll; - if (this.DrawToggleButtonWithTooltip("auto_scroll", "Auto-scroll", FontAwesomeIcon.Sync, ref this.autoScroll)) + var settingsPopup = ImGui.BeginPopup("##console_settings"); + if (settingsPopup) { - configuration.LogAutoScroll = !configuration.LogAutoScroll; - configuration.QueueSave(); + this.DrawSettingsPopup(configuration); + ImGui.EndPopup(); + } + else if (this.settingsPopupWasOpen) + { + // Prevent side effects in case Apply wasn't clicked + this.logLinesLimit = configuration.LogLinesLimit; } - ImGui.SameLine(); + this.settingsPopupWasOpen = settingsPopup; - this.autoOpen = configuration.LogOpenAtStartup; - if (this.DrawToggleButtonWithTooltip("auto_open", "Open at startup", FontAwesomeIcon.WindowRestore, ref this.autoOpen)) - { - configuration.LogOpenAtStartup = !configuration.LogOpenAtStartup; - configuration.QueueSave(); - } + if (this.DrawToggleButtonWithTooltip("show_settings", "Show settings", FontAwesomeIcon.List, ref settingsPopup)) ImGui.OpenPopup("##console_settings"); ImGui.SameLine(); @@ -447,6 +477,33 @@ internal class ConsoleWindow : Window, IDisposable } } + private void DrawSettingsPopup(DalamudConfiguration configuration) + { + if (ImGui.Checkbox("Open at startup", ref this.autoOpen)) + { + configuration.LogOpenAtStartup = this.autoOpen; + configuration.QueueSave(); + } + + if (ImGui.Checkbox("Auto-scroll", ref this.autoScroll)) + { + configuration.LogAutoScroll = this.autoScroll; + configuration.QueueSave(); + } + + ImGui.TextUnformatted("Logs buffer"); + ImGui.SliderInt("lines", ref this.logLinesLimit, LogLinesMinimum, LogLinesMaximum); + if (ImGui.Button("Apply")) + { + this.logLinesLimit = Math.Max(LogLinesMinimum, this.logLinesLimit); + + configuration.LogLinesLimit = this.logLinesLimit; + configuration.QueueSave(); + + ImGui.CloseCurrentPopup(); + } + } + private void DrawFilterToolbar() { if (!this.showFilterToolbar) return; @@ -686,8 +743,12 @@ internal class ConsoleWindow : Window, IDisposable this.logText.Add(entry); + var avoidScroll = this.FilteredLogEntries.Count == this.FilteredLogEntries.Size; if (this.IsFilterApplicable(entry)) + { this.FilteredLogEntries.Add(entry); + if (avoidScroll) Interlocked.Increment(ref this.newRolledLines); + } } private bool IsFilterApplicable(LogEntry entry) @@ -740,7 +801,7 @@ internal class ConsoleWindow : Window, IDisposable lock (this.renderLock) { this.regexError = false; - this.FilteredLogEntries = this.logText.Where(this.IsFilterApplicable).ToList(); + this.FilteredLogEntries = new RollingList(this.logText.Where(this.IsFilterApplicable), Math.Max(LogLinesMinimum, this.logLinesLimit)); } } @@ -789,6 +850,14 @@ internal class ConsoleWindow : Window, IDisposable return result; } + private void OnDalamudConfigurationSaved(DalamudConfiguration dalamudConfiguration) + { + this.logLinesLimit = dalamudConfiguration.LogLinesLimit; + var limit = Math.Max(LogLinesMinimum, this.logLinesLimit); + this.logText.Size = limit; + this.FilteredLogEntries.Size = limit; + } + private class LogEntry { public string Line { get; init; } = string.Empty; diff --git a/Dalamud/Utility/RollingList.cs b/Dalamud/Utility/RollingList.cs new file mode 100644 index 000000000..9ca012be4 --- /dev/null +++ b/Dalamud/Utility/RollingList.cs @@ -0,0 +1,234 @@ +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Runtime.CompilerServices; + +namespace Dalamud.Utility +{ + /// + /// A list with limited capacity holding items of type . + /// Adding further items will result in the list rolling over. + /// + /// Item type. + /// + /// Implemented as a circular list using a internally. + /// Insertions and Removals are not supported. + /// Not thread-safe. + /// + internal class RollingList : IList + { + private List items; + private int size; + private int firstIndex; + + /// Initializes a new instance of the class. + /// size. + /// Internal initial capacity. + public RollingList(int size, int capacity) + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(size), size, 0); + capacity = Math.Min(capacity, size); + this.size = size; + this.items = new List(capacity); + } + + /// Initializes a new instance of the class. + /// size. + public RollingList(int size) + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(size), size, 0); + this.size = size; + this.items = new(); + } + + /// Initializes a new instance of the class. + /// Collection where elements are copied from. + /// size. + public RollingList(IEnumerable items, int size) + { + if (!items.TryGetNonEnumeratedCount(out var capacity)) capacity = 4; + capacity = Math.Min(capacity, size); + this.size = size; + this.items = new List(capacity); + this.AddRange(items); + } + + /// Initializes a new instance of the class. + /// Collection where elements are copied from. + /// size. + /// Internal initial capacity. + public RollingList(IEnumerable items, int size, int capacity) + { + if (items.TryGetNonEnumeratedCount(out var count) && count > capacity) capacity = count; + capacity = Math.Min(capacity, size); + this.size = size; + this.items = new List(capacity); + this.AddRange(items); + } + + /// Gets item count. + public int Count => this.items.Count; + + /// Gets or sets the internal list capacity. + public int Capacity + { + get => this.items.Capacity; + set => this.items.Capacity = Math.Min(value, this.size); + } + + /// Gets or sets rolling list size. + public int Size + { + get => this.size; + set + { + if (value == this.size) return; + if (value > this.size) + { + if (this.firstIndex > 0) + { + this.items = new List(this); + this.firstIndex = 0; + } + } + else // value < this._size + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(value), value, 0); + if (value < this.Count) + { + this.items = new List(this.TakeLast(value)); + this.firstIndex = 0; + } + } + + this.size = value; + } + } + + /// Gets a value indicating whether the item is read only. + public bool IsReadOnly => false; + + /// Gets or sets an item by index. + /// Item index. + /// Item at specified index. + public T this[int index] + { + get + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfGreaterThanOrEqual(nameof(index), index, this.Count); + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(index), index, 0); + return this.items[this.GetRealIndex(index)]; + } + + set + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfGreaterThanOrEqual(nameof(index), index, this.Count); + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(index), index, 0); + this.items[this.GetRealIndex(index)] = value; + } + } + + /// Adds an item to this . + /// Item to add. + public void Add(T item) + { + if (this.size == 0) return; + if (this.items.Count >= this.size) + { + this.items[this.firstIndex] = item; + this.firstIndex = (this.firstIndex + 1) % this.size; + } + else + { + if (this.items.Count == this.items.Capacity) + { + // Manual list capacity resize + var newCapacity = Math.Max(Math.Min(this.size, this.items.Capacity * 2), this.items.Capacity); + this.items.Capacity = newCapacity; + } + + this.items.Add(item); + } + + Debug.Assert(this.items.Count <= this.size, "Item count should be less than Size"); + } + + /// Add items to this . + /// Items to add. + public void AddRange(IEnumerable items) + { + if (this.size == 0) return; + foreach (var item in items) this.Add(item); + } + + /// Removes all elements from the + public void Clear() + { + this.items.Clear(); + this.firstIndex = 0; + } + + /// Find the index of a specific item. + /// item to find. + /// Index where is found. -1 if not found. + public int IndexOf(T item) + { + var index = this.items.IndexOf(item); + if (index == -1) return -1; + return this.GetVirtualIndex(index); + } + + /// Not supported. + [SuppressMessage("Documentation Rules", "SA1611", Justification = "Not supported")] + void IList.Insert(int index, T item) => throw new NotSupportedException(); + + /// Not supported. + [SuppressMessage("Documentation Rules", "SA1611", Justification = "Not supported")] + void IList.RemoveAt(int index) => throw new NotSupportedException(); + + /// Find wether an item exists. + /// item to find. + /// Wether is found. + public bool Contains(T item) => this.items.Contains(item); + + /// Copies the content of this list into an array. + /// Array to copy into. + /// index to start coping into. + public void CopyTo(T[] array, int arrayIndex) + { + ThrowHelper.ThrowArgumentOutOfRangeExceptionIfLessThan(nameof(arrayIndex), arrayIndex, 0); + if (array.Length - arrayIndex < this.Count) ThrowHelper.ThrowArgumentException("Not enough space"); + for (var index = 0; index < this.Count; index++) + { + array[arrayIndex++] = this[index]; + } + } + + /// Not supported. + [SuppressMessage("Documentation Rules", "SA1611", Justification = "Not supported")] + [SuppressMessage("Documentation Rules", "SA1615", Justification = "Not supported")] + bool ICollection.Remove(T item) => throw new NotSupportedException(); + + /// Gets an enumerator for this . + /// enumerator. + public IEnumerator GetEnumerator() + { + for (var index = 0; index < this.items.Count; index++) + { + yield return this.items[this.GetRealIndex(index)]; + } + } + + /// Gets an enumerator for this . + /// enumerator. + IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int GetRealIndex(int index) => this.size > 0 ? (index + this.firstIndex) % this.size : 0; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int GetVirtualIndex(int index) => this.size > 0 ? (this.size + index - this.firstIndex) % this.size : 0; + } +} diff --git a/Dalamud/Utility/ThrowHelper.cs b/Dalamud/Utility/ThrowHelper.cs new file mode 100644 index 000000000..647aa92c0 --- /dev/null +++ b/Dalamud/Utility/ThrowHelper.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Dalamud.Utility +{ + /// Helper methods for throwing exceptions. + internal static class ThrowHelper + { + /// Throws a with a specified . + /// Message for the exception. + /// Thrown by this method. + [DoesNotReturn] + public static void ThrowArgumentException(string message) => throw new ArgumentException(message); + + /// Throws a with a specified for a specified . + /// Parameter name. + /// Message for the exception. + /// Thrown by this method. + [DoesNotReturn] + public static void ThrowArgumentOutOfRangeException(string paramName, string message) => throw new ArgumentOutOfRangeException(paramName, message); + + /// Throws a if the specified is less than . + /// value type. + /// Parameter name. + /// Value to compare from. + /// Value to compare with. + /// Thrown by this method if is less than . + public static void ThrowArgumentOutOfRangeExceptionIfLessThan(string paramName, T value, T comparand) where T : IComparable + { +#if NET8_0_OR_GREATER + ArgumentOutOfRangeException.ThrowIfLessThan(value, comparand); +#else + if (Comparer.Default.Compare(value, comparand) <= -1) ThrowArgumentOutOfRangeException(paramName, $"{paramName} must be greater than or equal {comparand}"); +#endif + } + + /// Throws a if the specified is greater than or equal to . + /// value type. + /// Parameter name. + /// Value to compare from. + /// Value to compare with. + /// Thrown by this method if is greater than or equal to. + public static void ThrowArgumentOutOfRangeExceptionIfGreaterThanOrEqual(string paramName, T value, T comparand) where T : IComparable + { +#if NET8_0_OR_GREATER + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(value, comparand); +#else + if (Comparer.Default.Compare(value, comparand) >= 0) ThrowArgumentOutOfRangeException(paramName, $"{paramName} must be less than {comparand}"); +#endif + } + } +}