using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Numerics; using System.Text; using System.Text.RegularExpressions; using Dalamud.Bindings.ImGui; 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 Serilog; using Serilog.Events; namespace Dalamud.Interface.Internal.Windows; /// /// The window that displays the Dalamud log file in-game. /// 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 logText; private readonly RollingList filteredLogEntries; private readonly List pluginFilters = []; 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; /// Initializes a new instance of the class. /// An instance of . public ConsoleWindow(DalamudConfiguration configuration) : base("Dalamud Console", ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse) { this.configuration = configuration; this.autoScroll = configuration.LogAutoScroll; this.autoOpen = configuration.LogOpenAtStartup; Service.GetAsync().ContinueWith(r => r.Result.Update += this.FrameworkOnUpdate); var cm = Service.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; this.clipperPtr = ImGui.ImGuiListClipper(); } /// Gets the queue where log entries that are not processed yet are stored. public static ConcurrentQueue<(string Line, LogEvent LogEvent)> NewLogEntries { get; } = new(); /// /// Gets or sets the current text filter. /// public string TextFilter { get => this.textFilter; set { this.textFilter = value; this.RecompileLogFilter(); } } /// public override void OnOpen() { this.killGameArmed = false; base.OnOpen(); } /// public void Dispose() { this.configuration.DalamudConfigurationSaved -= this.OnDalamudConfigurationSaved; if (Service.GetNullable() is { } framework) framework.Update -= this.FrameworkOnUpdate; this.clipperPtr.Destroy(); this.clipperPtr = default; } /// 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.Text(this.exceptionLogFilter.Message); } if (this.exceptionLogHighlight is not null) { ImGui.TextColored( ImGuiColors.DalamudRed, $"Regex Highlight Error: {this.exceptionLogHighlight.GetType().Name}"); ImGui.Text(this.exceptionLogHighlight.Message); } var sendButtonSize = ImGui.CalcTextSize("Send"u8) + ((new Vector2(16, 0) + (ImGui.GetStyle().FramePadding * 2)) * ImGuiHelpers.GlobalScale); var scrollingHeight = ImGui.GetContentRegionAvail().Y - sendButtonSize.Y; ImGui.BeginChild( "scrolling"u8, 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"u8).X; var levelWidth = ImGui.CalcTextSize("AAA"u8).X; var separatorWidth = ImGui.CalcTextSize(" | "u8).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"u8, 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.Text(line.TimestampString); ImGui.SameLine(); ImGui.SetCursorPosX(cursorLogLevel); ImGui.Text(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.Text(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"u8, 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"u8, 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.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"u8, $"{EntryPoint.LogLevelSwitch.MinimumLevel}+")) { foreach (var value in Enum.GetValues()) { 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"u8); 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"u8); 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"u8); 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"u8); 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"u8, "regex highlight"u8, 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"u8, "regex global filter"u8, ref this.textFilter, 2048, ImGuiInputTextFlags.EnterReturnsTrue | ImGuiInputTextFlags.AutoSelectAll) || ImGui.IsItemDeactivatedAfterEdit()) { this.RecompileLogFilter(); } } private void RecompileLogFilter() { 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"u8, ref this.autoOpen)) { this.configuration.LogOpenAtStartup = this.autoOpen; this.configuration.QueueSave(); } if (ImGui.Checkbox("Auto-scroll"u8, ref this.autoScroll)) { this.configuration.LogAutoScroll = this.autoScroll; this.configuration.QueueSave(); } ImGui.Text("Logs buffer"u8); ImGui.SliderInt("lines"u8, ref this.logLinesLimit, LogLinesMinimum, LogLinesMaximum); if (ImGui.Button("Apply"u8)) { 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"u8, 4, ImGuiTableFlags.Resizable | ImGuiTableFlags.BordersInnerV); if (!table) return; ImGui.TableSetupColumn("##remove_button"u8, ImGuiTableColumnFlags.WidthFixed, 25.0f * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("##source_name"u8, ImGuiTableColumnFlags.WidthFixed, 150.0f * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("##log_level"u8, ImGuiTableColumnFlags.WidthFixed, 150.0f * ImGuiHelpers.GlobalScale); ImGui.TableSetupColumn("##filter_text"u8, 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"u8, this.selectedSource, ImGuiComboFlags.HeightLarge)) { var sourceNames = Service.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"u8, "Filter Plugin List"u8, ref this.pluginFilter, 2048); ImGui.Separator(); if (sourceNames.Count == 0) { ImGui.TextColored(ImGuiColors.DalamudRed, "No Results"u8); } 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"u8, $"{entry.Level}+")) { foreach (var value in Enum.GetValues()) { 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"u8, $"{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.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 int CommandInputCallback(ref ImGuiInputTextCallbackData data) { switch (data.EventFlag) { case ImGuiInputTextFlags.CallbackEdit: this.completionZipText = null; this.completionTabIdx = 0; break; case ImGuiInputTextFlags.CallbackCompletion: var text = Encoding.UTF8.GetString(data.BufTextSpan); 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.Get().Entries .Where(x => x.Key.StartsWith(wordToComplete)) .Select(x => x.Key); candidates = candidates.Union( Service.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) { data.DeleteChars(0, data.BufTextLen); data.InsertChars(0, toComplete); } } break; case ImGuiInputTextFlags.CallbackHistory: var prevPos = this.historyPos; if (data.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; data.DeleteChars(0, data.BufTextLen); data.InsertChars(0, historyStr); } break; } return 0; } /// Add a log entry to the display. /// The line to add. /// The Serilog event associated with this line. /// Number of lines added to . 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; } /// Adds a line to the log list and the filtered log list accordingly. /// The new log entry to add. /// Number of lines added to . private int AddAndFilter(LogEntry entry) { ThreadSafety.DebugAssertMainThread(); this.logText.Add(entry); if (!this.IsFilterApplicable(entry)) return 0; this.filteredLogEntries.Add(entry); return 1; } /// Determines if a log entry passes the user-specified filter. /// The entry to test. /// true if it passes the filter. 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; } /// Queues clearing the window of all log entries, before next call to . private void QueueClear() => this.pendingClearLog = true; /// Queues filtering the log entries again, before next call to . 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 line, MatchCollection matches, uint col, uint highlightCol) { Span 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().Handle; 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; } /// /// Gets or sets the system responsible for generating this log entry. Generally will be a plugin's /// InternalName. /// 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; } } }