Dalamud/Dalamud/Interface/Internal/Windows/ConsoleWindow.cs
2024-07-21 19:09:05 +09:00

1203 lines
41 KiB
C#

using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using Dalamud.Configuration.Internal;
using Dalamud.Console;
using Dalamud.Game;
using Dalamud.Game.Command;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Components;
using Dalamud.Interface.ImGuiNotification;
using Dalamud.Interface.ImGuiNotification.Internal;
using Dalamud.Interface.Utility;
using Dalamud.Interface.Utility.Raii;
using Dalamud.Interface.Windowing;
using Dalamud.Plugin.Internal;
using Dalamud.Plugin.Services;
using Dalamud.Utility;
using ImGuiNET;
using Serilog;
using Serilog.Events;
namespace Dalamud.Interface.Internal.Windows;
/// <summary>
/// The window that displays the Dalamud log file in-game.
/// </summary>
internal class ConsoleWindow : Window, IDisposable
{
private const int LogLinesMinimum = 100;
private const int LogLinesMaximum = 1000000;
private const int HistorySize = 50;
// Fields below should be touched only from the main thread.
private readonly RollingList<LogEntry> logText;
private readonly RollingList<LogEntry> filteredLogEntries;
private readonly List<PluginFilterEntry> pluginFilters = new();
private readonly DalamudConfiguration configuration;
private int newRolledLines;
private bool pendingRefilter;
private bool pendingClearLog;
private bool? lastCmdSuccess;
private ImGuiListClipperPtr clipperPtr;
private string commandText = string.Empty;
private string textFilter = string.Empty;
private string textHighlight = string.Empty;
private string selectedSource = "DalamudInternal";
private string pluginFilter = string.Empty;
private Regex? compiledLogFilter;
private Regex? compiledLogHighlight;
private Exception? exceptionLogFilter;
private Exception? exceptionLogHighlight;
private bool filterShowUncaughtExceptions;
private bool settingsPopupWasOpen;
private bool showFilterToolbar;
private bool copyMode;
private bool killGameArmed;
private bool autoScroll;
private int logLinesLimit;
private bool autoOpen;
private int historyPos;
private int copyStart = -1;
private string? completionZipText = null;
private int completionTabIdx = 0;
private IActiveNotification? prevCopyNotification;
/// <summary>Initializes a new instance of the <see cref="ConsoleWindow"/> class.</summary>
/// <param name="configuration">An instance of <see cref="DalamudConfiguration"/>.</param>
public ConsoleWindow(DalamudConfiguration configuration)
: base("Dalamud Console", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse)
{
this.configuration = configuration;
this.autoScroll = configuration.LogAutoScroll;
this.autoOpen = configuration.LogOpenAtStartup;
Service<Framework>.GetAsync().ContinueWith(r => r.Result.Update += this.FrameworkOnUpdate);
var cm = Service<ConsoleManager>.Get();
cm.AddCommand("clear", "Clear the console log", () =>
{
this.QueueClear();
return true;
});
cm.AddAlias("clear", "cls");
this.Size = new Vector2(500, 400);
this.SizeCondition = ImGuiCond.FirstUseEver;
this.RespectCloseHotkey = false;
this.logLinesLimit = configuration.LogLinesLimit;
var limit = Math.Max(LogLinesMinimum, this.logLinesLimit);
this.logText = new(limit);
this.filteredLogEntries = new(limit);
this.configuration.DalamudConfigurationSaved += this.OnDalamudConfigurationSaved;
unsafe
{
this.clipperPtr = new(ImGuiNative.ImGuiListClipper_ImGuiListClipper());
}
}
/// <summary>Gets the queue where log entries that are not processed yet are stored.</summary>
public static ConcurrentQueue<(string Line, LogEvent LogEvent)> NewLogEntries { get; } = new();
/// <inheritdoc/>
public override void OnOpen()
{
this.killGameArmed = false;
base.OnOpen();
}
/// <inheritdoc/>
public void Dispose()
{
this.configuration.DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved;
if (Service<Framework>.GetNullable() is { } framework)
framework.Update -= this.FrameworkOnUpdate;
this.clipperPtr.Destroy();
this.clipperPtr = default;
}
/// <inheritdoc/>
public override void Draw()
{
this.DrawOptionsToolbar();
this.DrawFilterToolbar();
if (this.exceptionLogFilter is not null)
{
ImGui.TextColored(
ImGuiColors.DalamudRed,
$"Regex Filter Error: {this.exceptionLogFilter.GetType().Name}");
ImGui.TextUnformatted(this.exceptionLogFilter.Message);
}
if (this.exceptionLogHighlight is not null)
{
ImGui.TextColored(
ImGuiColors.DalamudRed,
$"Regex Highlight Error: {this.exceptionLogHighlight.GetType().Name}");
ImGui.TextUnformatted(this.exceptionLogHighlight.Message);
}
var sendButtonSize = ImGui.CalcTextSize("Send") +
((new Vector2(16, 0) + (ImGui.GetStyle().FramePadding * 2)) * ImGuiHelpers.GlobalScale);
var scrollingHeight = ImGui.GetContentRegionAvail().Y - sendButtonSize.Y;
ImGui.BeginChild(
"scrolling",
new Vector2(0, scrollingHeight),
false,
ImGuiWindowFlags.AlwaysHorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar);
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, Vector2.Zero);
ImGui.PushFont(InterfaceManager.MonoFont);
var childPos = ImGui.GetWindowPos();
var childDrawList = ImGui.GetWindowDrawList();
var childSize = ImGui.GetWindowSize();
var timestampWidth = ImGui.CalcTextSize("00:00:00.000").X;
var levelWidth = ImGui.CalcTextSize("AAA").X;
var separatorWidth = ImGui.CalcTextSize(" | ").X;
var cursorLogLevel = timestampWidth + separatorWidth;
var cursorLogLine = cursorLogLevel + levelWidth + separatorWidth;
var lastLinePosY = 0.0f;
var logLineHeight = 0.0f;
this.clipperPtr.Begin(this.filteredLogEntries.Count);
while (this.clipperPtr.Step())
{
for (var i = this.clipperPtr.DisplayStart; i < this.clipperPtr.DisplayEnd; 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)
ImGui.Separator();
if (line.SelectedForCopy)
{
ImGui.PushStyleColor(ImGuiCol.Header, ImGuiColors.ParsedGrey);
ImGui.PushStyleColor(ImGuiCol.HeaderActive, ImGuiColors.ParsedGrey);
ImGui.PushStyleColor(ImGuiCol.HeaderHovered, ImGuiColors.ParsedGrey);
}
else
{
ImGui.PushStyleColor(ImGuiCol.Header, GetColorForLogEventLevel(line.Level));
ImGui.PushStyleColor(ImGuiCol.HeaderActive, GetColorForLogEventLevel(line.Level));
ImGui.PushStyleColor(ImGuiCol.HeaderHovered, GetColorForLogEventLevel(line.Level));
}
ImGui.Selectable(
"###console_null",
true,
ImGuiSelectableFlags.AllowItemOverlap | ImGuiSelectableFlags.SpanAllColumns);
// This must be after ImGui.Selectable, it uses ImGui.IsItem... functions
this.HandleCopyMode(i, line);
ImGui.SameLine();
ImGui.PopStyleColor(3);
if (!line.IsMultiline)
{
ImGui.TextUnformatted(line.TimestampString);
ImGui.SameLine();
ImGui.SetCursorPosX(cursorLogLevel);
ImGui.TextUnformatted(GetTextForLogEventLevel(line.Level));
ImGui.SameLine();
}
ImGui.SetCursorPosX(cursorLogLine);
line.HighlightMatches ??= (this.compiledLogHighlight ?? this.compiledLogFilter)?.Matches(line.Line);
if (line.HighlightMatches is { } matches)
{
this.DrawHighlighted(
line.Line,
matches,
ImGui.GetColorU32(ImGuiCol.Text),
ImGui.GetColorU32(ImGuiColors.HealerGreen));
}
else
{
ImGui.TextUnformatted(line.Line);
}
var currentLinePosY = ImGui.GetCursorPosY();
logLineHeight = currentLinePosY - lastLinePosY;
lastLinePosY = currentLinePosY;
}
}
this.clipperPtr.End();
ImGui.PopFont();
ImGui.PopStyleVar();
if (!this.autoScroll || ImGui.GetScrollY() < ImGui.GetScrollMaxY())
{
ImGui.SetScrollY(ImGui.GetScrollY() - (logLineHeight * this.newRolledLines));
}
if (this.autoScroll && ImGui.GetScrollY() >= ImGui.GetScrollMaxY())
{
ImGui.SetScrollHereY(1.0f);
}
// Draw dividing lines
var div1Offset = MathF.Round((timestampWidth + (separatorWidth / 2)) - ImGui.GetScrollX());
var div2Offset = MathF.Round((cursorLogLevel + levelWidth + (separatorWidth / 2)) - ImGui.GetScrollX());
childDrawList.AddLine(
new(childPos.X + div1Offset, childPos.Y),
new(childPos.X + div1Offset, childPos.Y + childSize.Y),
0x4FFFFFFF,
1.0f);
childDrawList.AddLine(
new(childPos.X + div2Offset, childPos.Y),
new(childPos.X + div2Offset, childPos.Y + childSize.Y),
0x4FFFFFFF,
1.0f);
ImGui.EndChild();
var hadColor = false;
if (this.lastCmdSuccess.HasValue)
{
hadColor = true;
if (this.lastCmdSuccess.Value)
{
ImGui.PushStyleColor(ImGuiCol.FrameBg, ImGuiColors.HealerGreen - new Vector4(0, 0, 0, 0.7f));
}
else
{
ImGui.PushStyleColor(ImGuiCol.FrameBg, ImGuiColors.DalamudRed - new Vector4(0, 0, 0, 0.7f));
}
}
ImGui.SetNextItemWidth(
ImGui.GetContentRegionAvail().X - sendButtonSize.X -
(ImGui.GetStyle().ItemSpacing.X * ImGuiHelpers.GlobalScale));
var getFocus = false;
unsafe
{
if (ImGui.InputText(
"##command_box",
ref this.commandText,
255,
ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.CallbackCompletion |
ImGuiInputTextFlags.CallbackHistory | ImGuiInputTextFlags.CallbackEdit,
this.CommandInputCallback))
{
NewLogEntries.Enqueue((this.commandText, new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, new MessageTemplate(string.Empty, []), [])));
this.ProcessCommand();
getFocus = true;
}
ImGui.SameLine();
}
ImGui.SetItemDefaultFocus();
if (getFocus) ImGui.SetKeyboardFocusHere(-1); // Auto focus previous widget
if (hadColor) ImGui.PopStyleColor();
if (ImGui.Button("Send", sendButtonSize))
{
this.ProcessCommand();
}
}
private static string GetTextForLogEventLevel(LogEventLevel level) => level switch
{
LogEventLevel.Error => "ERR",
LogEventLevel.Verbose => "VRB",
LogEventLevel.Debug => "DBG",
LogEventLevel.Information => "INF",
LogEventLevel.Warning => "WRN",
LogEventLevel.Fatal => "FTL",
_ => "???",
};
private static uint GetColorForLogEventLevel(LogEventLevel level) => level switch
{
LogEventLevel.Error => 0x800000EE,
LogEventLevel.Verbose => 0x00000000,
LogEventLevel.Debug => 0x00000000,
LogEventLevel.Information => 0x00000000,
LogEventLevel.Warning => 0x8A0070EE,
LogEventLevel.Fatal => 0x800000EE,
_ => 0x30FFFFFF,
};
private void FrameworkOnUpdate(IFramework framework)
{
if (this.pendingClearLog)
{
this.pendingClearLog = false;
this.logText.Clear();
this.filteredLogEntries.Clear();
NewLogEntries.Clear();
}
if (this.pendingRefilter)
{
this.pendingRefilter = false;
this.filteredLogEntries.Clear();
foreach (var log in this.logText)
{
if (this.IsFilterApplicable(log))
this.filteredLogEntries.Add(log);
}
}
var numPrevFilteredLogEntries = this.filteredLogEntries.Count;
var addedLines = 0;
while (NewLogEntries.TryDequeue(out var logLine))
addedLines += this.HandleLogLine(logLine.Line, logLine.LogEvent);
this.newRolledLines = addedLines - (this.filteredLogEntries.Count - numPrevFilteredLogEntries);
}
private void HandleCopyMode(int i, LogEntry line)
{
var selectionChanged = false;
// If copyStart is -1, it means a drag has not been started yet, let's start one, and select the starting spot.
if (this.copyMode && this.copyStart == -1 && ImGui.IsItemClicked())
{
this.copyStart = i;
line.SelectedForCopy = !line.SelectedForCopy;
selectionChanged = true;
}
// Update the selected range when dragging over entries
if (this.copyMode && this.copyStart != -1 && ImGui.IsItemHovered() &&
ImGui.IsMouseDragging(ImGuiMouseButton.Left))
{
if (!line.SelectedForCopy)
{
foreach (var index in Enumerable.Range(0, this.filteredLogEntries.Count))
{
if (this.copyStart < i)
{
this.filteredLogEntries[index].SelectedForCopy = index >= this.copyStart && index <= i;
}
else
{
this.filteredLogEntries[index].SelectedForCopy = index >= i && index <= this.copyStart;
}
}
selectionChanged = true;
}
}
// Finish the drag, we should have already marked all dragged entries as selected by now.
if (this.copyMode && this.copyStart != -1 && ImGui.IsItemHovered() &&
ImGui.IsMouseReleased(ImGuiMouseButton.Left))
{
this.copyStart = -1;
}
if (selectionChanged)
this.CopyFilteredLogEntries(true);
}
private void CopyFilteredLogEntries(bool selectedOnly)
{
var sb = new StringBuilder();
var n = 0;
foreach (var entry in this.filteredLogEntries)
{
if (selectedOnly && !entry.SelectedForCopy)
continue;
n++;
sb.AppendLine(entry.ToString());
}
if (n == 0)
return;
ImGui.SetClipboardText(sb.ToString());
this.prevCopyNotification?.DismissNow();
this.prevCopyNotification = Service<NotificationManager>.Get().AddNotification(
new()
{
Title = this.WindowName,
Content = $"{n:n0} line(s) copied.",
Type = NotificationType.Success,
});
}
private void DrawOptionsToolbar()
{
ImGui.PushItemWidth(150.0f * ImGuiHelpers.GlobalScale);
if (ImGui.BeginCombo("##log_level", $"{EntryPoint.LogLevelSwitch.MinimumLevel}+"))
{
foreach (var value in Enum.GetValues<LogEventLevel>())
{
if (ImGui.Selectable(value.ToString(), value == EntryPoint.LogLevelSwitch.MinimumLevel))
{
EntryPoint.LogLevelSwitch.MinimumLevel = value;
this.configuration.LogLevel = value;
this.configuration.QueueSave();
this.QueueRefilter();
}
}
ImGui.EndCombo();
}
ImGui.SameLine();
var settingsPopup = ImGui.BeginPopup("##console_settings");
if (settingsPopup)
{
this.DrawSettingsPopup();
ImGui.EndPopup();
}
else if (this.settingsPopupWasOpen)
{
// Prevent side effects in case Apply wasn't clicked
this.logLinesLimit = this.configuration.LogLinesLimit;
}
this.settingsPopupWasOpen = settingsPopup;
if (this.DrawToggleButtonWithTooltip("show_settings", "Show settings", FontAwesomeIcon.List, ref settingsPopup))
ImGui.OpenPopup("##console_settings");
ImGui.SameLine();
if (this.DrawToggleButtonWithTooltip(
"show_filters",
"Show filter toolbar",
FontAwesomeIcon.Search,
ref this.showFilterToolbar))
{
this.showFilterToolbar = !this.showFilterToolbar;
}
ImGui.SameLine();
if (this.DrawToggleButtonWithTooltip(
"show_uncaught_exceptions",
"Show uncaught exception while filtering",
FontAwesomeIcon.Bug,
ref this.filterShowUncaughtExceptions))
{
this.filterShowUncaughtExceptions = !this.filterShowUncaughtExceptions;
}
ImGui.SameLine();
if (ImGuiComponents.IconButton("clear_log", FontAwesomeIcon.Trash))
{
this.QueueClear();
}
if (ImGui.IsItemHovered()) ImGui.SetTooltip("Clear Log");
ImGui.SameLine();
if (this.DrawToggleButtonWithTooltip(
"copy_mode",
"Enable Copy Mode\nRight-click to copy entire log",
FontAwesomeIcon.Copy,
ref this.copyMode))
{
this.copyMode = !this.copyMode;
if (!this.copyMode)
{
foreach (var entry in this.filteredLogEntries)
{
entry.SelectedForCopy = false;
}
}
}
if (ImGui.IsItemClicked(ImGuiMouseButton.Right))
this.CopyFilteredLogEntries(false);
ImGui.SameLine();
if (this.killGameArmed)
{
if (ImGuiComponents.IconButton(FontAwesomeIcon.ExclamationTriangle))
Process.GetCurrentProcess().Kill();
}
else
{
if (ImGuiComponents.IconButton(FontAwesomeIcon.Stop))
this.killGameArmed = true;
}
if (ImGui.IsItemHovered()) ImGui.SetTooltip("Kill game");
ImGui.SameLine();
var inputWidth = 200.0f * ImGuiHelpers.GlobalScale;
var nextCursorPosX = ImGui.GetContentRegionMax().X - (2 * inputWidth) - ImGui.GetStyle().ItemSpacing.X;
var breakInputLines = nextCursorPosX < 0;
if (ImGui.GetCursorPosX() > nextCursorPosX)
{
ImGui.NewLine();
inputWidth = ImGui.GetWindowWidth() - (ImGui.GetStyle().WindowPadding.X * 2);
if (!breakInputLines)
inputWidth = (inputWidth - ImGui.GetStyle().ItemSpacing.X) / 2;
}
else
{
ImGui.SetCursorPosX(nextCursorPosX);
}
ImGui.PushItemWidth(inputWidth);
if (ImGui.InputTextWithHint(
"##textHighlight",
"regex highlight",
ref this.textHighlight,
2048,
ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll)
|| ImGui.IsItemDeactivatedAfterEdit())
{
this.compiledLogHighlight = null;
this.exceptionLogHighlight = null;
try
{
if (this.textHighlight != string.Empty)
this.compiledLogHighlight = new(this.textHighlight, RegexOptions.IgnoreCase);
}
catch (Exception e)
{
this.exceptionLogHighlight = e;
}
foreach (var log in this.logText)
log.HighlightMatches = null;
}
if (!breakInputLines)
ImGui.SameLine();
ImGui.PushItemWidth(inputWidth);
if (ImGui.InputTextWithHint(
"##textFilter",
"regex global filter",
ref this.textFilter,
2048,
ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll)
|| ImGui.IsItemDeactivatedAfterEdit())
{
this.compiledLogFilter = null;
this.exceptionLogFilter = null;
try
{
this.compiledLogFilter = new(this.textFilter, RegexOptions.IgnoreCase);
this.QueueRefilter();
}
catch (Exception e)
{
this.exceptionLogFilter = e;
}
foreach (var log in this.logText)
log.HighlightMatches = null;
}
}
private void DrawSettingsPopup()
{
if (ImGui.Checkbox("Open at startup", ref this.autoOpen))
{
this.configuration.LogOpenAtStartup = this.autoOpen;
this.configuration.QueueSave();
}
if (ImGui.Checkbox("Auto-scroll", ref this.autoScroll))
{
this.configuration.LogAutoScroll = this.autoScroll;
this.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);
this.configuration.LogLinesLimit = this.logLinesLimit;
this.configuration.QueueSave();
ImGui.CloseCurrentPopup();
}
}
private void DrawFilterToolbar()
{
if (!this.showFilterToolbar) return;
PluginFilterEntry? removalEntry = null;
using var table = ImRaii.Table(
"plugin_filter_entries",
4,
ImGuiTableFlags.Resizable | ImGuiTableFlags.BordersInnerV);
if (!table) return;
ImGui.TableSetupColumn("##remove_button", ImGuiTableColumnFlags.WidthFixed, 25.0f * ImGuiHelpers.GlobalScale);
ImGui.TableSetupColumn("##source_name", ImGuiTableColumnFlags.WidthFixed, 150.0f * ImGuiHelpers.GlobalScale);
ImGui.TableSetupColumn("##log_level", ImGuiTableColumnFlags.WidthFixed, 150.0f * ImGuiHelpers.GlobalScale);
ImGui.TableSetupColumn("##filter_text", ImGuiTableColumnFlags.WidthStretch);
ImGui.TableNextColumn();
if (ImGuiComponents.IconButton("add_entry", FontAwesomeIcon.Plus))
{
if (this.pluginFilters.All(entry => entry.Source != this.selectedSource))
{
this.pluginFilters.Add(
new PluginFilterEntry
{
Source = this.selectedSource,
Filter = string.Empty,
Level = LogEventLevel.Debug,
});
}
this.QueueRefilter();
}
ImGui.TableNextColumn();
ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X);
if (ImGui.BeginCombo("##Sources", this.selectedSource, ImGuiComboFlags.HeightLarge))
{
var sourceNames = Service<PluginManager>.Get().InstalledPlugins
.Select(p => p.Manifest.InternalName)
.OrderBy(s => s)
.Prepend("DalamudInternal")
.Where(
name => this.pluginFilter is "" || new FuzzyMatcher(
this.pluginFilter.ToLowerInvariant(),
MatchMode.Fuzzy).Matches(name.ToLowerInvariant()) !=
0)
.ToList();
ImGui.PushItemWidth(ImGui.GetContentRegionAvail().X);
ImGui.InputTextWithHint("##PluginSearchFilter", "Filter Plugin List", ref this.pluginFilter, 2048);
ImGui.Separator();
if (!sourceNames.Any())
{
ImGui.TextColored(ImGuiColors.DalamudRed, "No Results");
}
foreach (var selectable in sourceNames)
{
if (ImGui.Selectable(selectable, this.selectedSource == selectable))
{
this.selectedSource = selectable;
}
}
ImGui.EndCombo();
}
ImGui.TableNextColumn();
ImGui.TableNextColumn();
foreach (var entry in this.pluginFilters)
{
ImGui.PushID(entry.Source);
ImGui.TableNextColumn();
if (ImGuiComponents.IconButton(FontAwesomeIcon.Trash))
{
removalEntry = entry;
}
ImGui.TableNextColumn();
ImGui.Text(entry.Source);
ImGui.TableNextColumn();
ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
if (ImGui.BeginCombo("##levels", $"{entry.Level}+"))
{
foreach (var value in Enum.GetValues<LogEventLevel>())
{
if (ImGui.Selectable(value.ToString(), value == entry.Level))
{
entry.Level = value;
this.QueueRefilter();
}
}
ImGui.EndCombo();
}
ImGui.TableNextColumn();
ImGui.SetNextItemWidth(ImGui.GetContentRegionAvail().X);
var entryFilter = entry.Filter;
if (ImGui.InputTextWithHint(
"##filter",
$"{entry.Source} regex filter",
ref entryFilter,
2048,
ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll)
|| ImGui.IsItemDeactivatedAfterEdit())
{
entry.Filter = entryFilter;
if (entry.FilterException is null)
this.QueueRefilter();
}
ImGui.PopID();
}
if (removalEntry is { } toRemove)
{
this.pluginFilters.Remove(toRemove);
this.QueueRefilter();
}
}
private void ProcessCommand()
{
try
{
if (string.IsNullOrEmpty(this.commandText))
return;
this.historyPos = -1;
if (this.commandText != this.configuration.LogCommandHistory.LastOrDefault())
this.configuration.LogCommandHistory.Add(this.commandText);
if (this.configuration.LogCommandHistory.Count > HistorySize)
this.configuration.LogCommandHistory.RemoveAt(0);
this.configuration.QueueSave();
this.lastCmdSuccess = Service<ConsoleManager>.Get().ProcessCommand(this.commandText);
this.commandText = string.Empty;
// TODO: Force scroll to bottom
}
catch (Exception ex)
{
Log.Error(ex, "Error during command dispatch");
this.lastCmdSuccess = false;
}
}
private unsafe int CommandInputCallback(ImGuiInputTextCallbackData* data)
{
var ptr = new ImGuiInputTextCallbackDataPtr(data);
switch (data->EventFlag)
{
case ImGuiInputTextFlags.CallbackEdit:
this.completionZipText = null;
this.completionTabIdx = 0;
break;
case ImGuiInputTextFlags.CallbackCompletion:
var textBytes = new byte[data->BufTextLen];
Marshal.Copy((IntPtr)data->Buf, textBytes, 0, data->BufTextLen);
var text = Encoding.UTF8.GetString(textBytes);
var words = text.Split();
// We can't do any completion for parameters at the moment since it just calls into CommandHandler
if (words.Length > 1)
return 0;
var wordToComplete = words[0];
if (wordToComplete.IsNullOrWhitespace())
return 0;
if (this.completionZipText is not null)
wordToComplete = this.completionZipText;
// TODO: Improve this, add partial completion, arguments, description, etc.
// https://github.com/ocornut/imgui/blob/master/imgui_demo.cpp#L6443-L6484
var candidates = Service<ConsoleManager>.Get().Entries
.Where(x => x.Key.StartsWith(wordToComplete))
.Select(x => x.Key);
candidates = candidates.Union(
Service<CommandManager>.Get().Commands
.Where(x => x.Key.StartsWith(wordToComplete)).Select(x => x.Key))
.ToArray();
if (candidates.Any())
{
string? toComplete = null;
if (this.completionZipText == null)
{
// Find the "common" prefix of all matches
toComplete = candidates.Aggregate(
(prefix, candidate) => string.Concat(prefix.Zip(candidate, (a, b) => a == b ? a : '\0')));
this.completionZipText = toComplete;
}
else
{
toComplete = candidates.ElementAt(this.completionTabIdx);
this.completionTabIdx = (this.completionTabIdx + 1) % candidates.Count();
}
if (toComplete != null)
{
ptr.DeleteChars(0, ptr.BufTextLen);
ptr.InsertChars(0, toComplete);
}
}
break;
case ImGuiInputTextFlags.CallbackHistory:
var prevPos = this.historyPos;
if (ptr.EventKey == ImGuiKey.UpArrow)
{
if (this.historyPos == -1)
this.historyPos = this.configuration.LogCommandHistory.Count - 1;
else if (this.historyPos > 0)
this.historyPos--;
}
else if (data->EventKey == ImGuiKey.DownArrow)
{
if (this.historyPos != -1)
{
if (++this.historyPos >= this.configuration.LogCommandHistory.Count)
{
this.historyPos = -1;
}
}
}
if (prevPos != this.historyPos)
{
var historyStr = this.historyPos >= 0 ? this.configuration.LogCommandHistory[this.historyPos] : string.Empty;
ptr.DeleteChars(0, ptr.BufTextLen);
ptr.InsertChars(0, historyStr);
}
break;
}
return 0;
}
/// <summary>Add a log entry to the display.</summary>
/// <param name="line">The line to add.</param>
/// <param name="logEvent">The Serilog event associated with this line.</param>
/// <returns>Number of lines added to <see cref="filteredLogEntries"/>.</returns>
private int HandleLogLine(string line, LogEvent logEvent)
{
ThreadSafety.DebugAssertMainThread();
// These lines are too huge, and only useful for troubleshooting after the game exist.
if (line.StartsWith("TROUBLESHOOTING:") || line.StartsWith("LASTEXCEPTION:"))
return 0;
// Create a log entry template.
var entry = new LogEntry
{
Level = logEvent.Level,
TimeStamp = logEvent.Timestamp,
HasException = logEvent.Exception != null,
};
if (logEvent.Properties.ContainsKey("Dalamud.ModuleName"))
{
entry.Source = "DalamudInternal";
}
else if (logEvent.Properties.TryGetValue("Dalamud.PluginName", out var sourceProp) &&
sourceProp is ScalarValue { Value: string sourceValue })
{
entry.Source = sourceValue;
}
var ssp = line.AsSpan();
var numLines = 0;
while (true)
{
var next = ssp.IndexOfAny('\r', '\n');
if (next == -1)
{
// Last occurrence; transfer the ownership of the new entry to the queue.
entry.Line = ssp.ToString();
numLines += this.AddAndFilter(entry);
break;
}
// There will be more; create a clone of the entry with the current line.
numLines += this.AddAndFilter(entry with { Line = ssp[..next].ToString() });
// Mark further lines as multiline.
entry.IsMultiline = true;
// Skip the detected line break.
ssp = ssp[next..];
ssp = ssp.StartsWith("\r\n") ? ssp[2..] : ssp[1..];
}
return numLines;
}
/// <summary>Adds a line to the log list and the filtered log list accordingly.</summary>
/// <param name="entry">The new log entry to add.</param>
/// <returns>Number of lines added to <see cref="filteredLogEntries"/>.</returns>
private int AddAndFilter(LogEntry entry)
{
ThreadSafety.DebugAssertMainThread();
this.logText.Add(entry);
if (!this.IsFilterApplicable(entry))
return 0;
this.filteredLogEntries.Add(entry);
return 1;
}
/// <summary>Determines if a log entry passes the user-specified filter.</summary>
/// <param name="entry">The entry to test.</param>
/// <returns><c>true</c> if it passes the filter.</returns>
private bool IsFilterApplicable(LogEntry entry)
{
ThreadSafety.DebugAssertMainThread();
if (this.exceptionLogFilter is not null)
return false;
// If this entry is below a newly set minimum level, fail it
if (EntryPoint.LogLevelSwitch.MinimumLevel > entry.Level)
return false;
// Show exceptions that weren't properly tagged with a Source (generally meaning they were uncaught)
// After log levels because uncaught exceptions should *never* fall below Error.
if (this.filterShowUncaughtExceptions && entry.HasException && entry.Source == null)
return true;
// (global filter) && (plugin filter) must be satisfied.
var wholeCond = true;
// If we have a global filter, check that first
if (this.compiledLogFilter is { } logFilter)
{
// Someone will definitely try to just text filter a source without using the actual filters, should allow that.
var matchesSource = entry.Source is not null && logFilter.IsMatch(entry.Source);
var matchesContent = logFilter.IsMatch(entry.Line);
wholeCond &= matchesSource || matchesContent;
}
// If this entry has a filter, check the filter
if (this.pluginFilters.Count > 0)
{
var matchesAny = false;
foreach (var filterEntry in this.pluginFilters)
{
if (!string.Equals(filterEntry.Source, entry.Source, StringComparison.InvariantCultureIgnoreCase))
continue;
var allowedLevel = filterEntry.Level <= entry.Level;
var matchesContent = filterEntry.FilterRegex?.IsMatch(entry.Line) is not false;
matchesAny |= allowedLevel && matchesContent;
if (matchesAny)
break;
}
wholeCond &= matchesAny;
}
return wholeCond;
}
/// <summary>Queues clearing the window of all log entries, before next call to <see cref="Draw"/>.</summary>
private void QueueClear() => this.pendingClearLog = true;
/// <summary>Queues filtering the log entries again, before next call to <see cref="Draw"/>.</summary>
private void QueueRefilter() => this.pendingRefilter = true;
private bool DrawToggleButtonWithTooltip(
string buttonId, string tooltip, FontAwesomeIcon icon, ref bool enabledState)
{
var result = false;
var buttonEnabled = enabledState;
if (buttonEnabled) ImGui.PushStyleColor(ImGuiCol.Button, ImGuiColors.HealerGreen with { W = 0.25f });
if (ImGuiComponents.IconButton(buttonId, icon))
{
result = true;
}
if (ImGui.IsItemHovered()) ImGui.SetTooltip(tooltip);
if (buttonEnabled) ImGui.PopStyleColor();
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 unsafe void DrawHighlighted(
ReadOnlySpan<char> line,
MatchCollection matches,
uint col,
uint highlightCol)
{
Span<int> charOffsets = stackalloc int[(matches.Count * 2) + 2];
var charOffsetsIndex = 1;
for (var j = 0; j < matches.Count; j++)
{
var g = matches[j].Groups[0];
charOffsets[charOffsetsIndex++] = g.Index;
charOffsets[charOffsetsIndex++] = g.Index + g.Length;
}
charOffsets[charOffsetsIndex++] = line.Length;
var screenPos = ImGui.GetCursorScreenPos();
var drawList = ImGui.GetWindowDrawList().NativePtr;
var font = ImGui.GetFont();
var size = ImGui.GetFontSize();
var scale = size / font.FontSize;
var hotData = font.IndexedHotDataWrapped();
var lookup = font.IndexLookupWrapped();
var kern = (ImGui.GetIO().ConfigFlags & ImGuiConfigFlags.NoKerning) == 0;
var lastc = '\0';
for (var i = 0; i < charOffsetsIndex - 1; i++)
{
var begin = charOffsets[i];
var end = charOffsets[i + 1];
if (begin == end)
continue;
for (var j = begin; j < end; j++)
{
var currc = line[j];
if (currc >= lookup.Length || lookup[currc] == ushort.MaxValue)
currc = (char)font.FallbackChar;
if (kern)
screenPos.X += scale * ImGui.GetFont().GetDistanceAdjustmentForPair(lastc, currc);
font.RenderChar(drawList, size, screenPos, i % 2 == 1 ? highlightCol : col, currc);
screenPos.X += scale * hotData[currc].AdvanceX;
lastc = currc;
}
}
ImGui.Dummy(screenPos - ImGui.GetCursorScreenPos());
}
private record LogEntry
{
public string Line { get; set; } = string.Empty;
public LogEventLevel Level { get; init; }
public DateTimeOffset TimeStamp { get; init; }
public bool IsMultiline { get; set; }
/// <summary>
/// Gets or sets the system responsible for generating this log entry. Generally will be a plugin's
/// InternalName.
/// </summary>
public string? Source { get; set; }
public bool SelectedForCopy { get; set; }
public bool HasException { get; init; }
public MatchCollection? HighlightMatches { get; set; }
public string TimestampString => this.TimeStamp.ToString("HH:mm:ss.fff");
public override string ToString() =>
this.IsMultiline
? $"\t{this.Line}"
: $"{this.TimestampString} | {GetTextForLogEventLevel(this.Level)} | {this.Line}";
}
private class PluginFilterEntry
{
private string filter = string.Empty;
public string Source { get; init; } = string.Empty;
public string Filter
{
get => this.filter;
set
{
this.filter = value;
this.FilterRegex = null;
this.FilterException = null;
if (value == string.Empty)
return;
try
{
this.FilterRegex = new(value, RegexOptions.IgnoreCase);
}
catch (Exception e)
{
this.FilterException = e;
}
}
}
public LogEventLevel Level { get; set; }
public Regex? FilterRegex { get; private set; }
public Exception? FilterException { get; private set; }
}
}