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
+ }
+ }
+}