diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index ae7a9b8..06683a4 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -1,10 +1,13 @@ using System.Collections; +using System.Linq; +using System.Text.RegularExpressions; using Dalamud; using Dalamud.Bindings.ImGui; using Dalamud.Configuration; using Dalamud.Game.ClientState.Keys; using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface.FontIdentifier; +using HellionChat._Helpers; using HellionChat.Code; using HellionChat.GameFunctions.Types; using HellionChat.Resources; @@ -459,6 +462,10 @@ public class Tab public bool EnableNotificationSound; public uint NotificationSoundId = 1; + // UI-8: optional regex applied on top of the channel filter. Null or + // empty means no filter, today's behaviour unchanged. + public string? MessageRegex; + [NonSerialized] public uint Unread; @@ -512,11 +519,32 @@ public class Tab [NonSerialized] internal float _cardHoverAlpha; + // UI-8: compiled-regex cache. Recompiled only when MessageRegex changes; + // _compiledRegexKey is the validation key (mirrors the _cachedTint* keys). + [NonSerialized] + private Regex? _compiledRegex; + + [NonSerialized] + private string? _compiledRegexKey; + public bool Matches(Message message) { if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels)) return false; + // UI-8: optional regex content filter, AND-constraint after the channel + // check. An empty pattern or an invalid one (cached regex is null) lets + // every message through, so existing tabs behave unchanged. + if (!string.IsNullOrEmpty(MessageRegex)) + { + var text = string.Join( + "", + message.Content.OfType().Select(c => c.Content) + ); + if (!RegexRouteMatcher.IsMatch(GetCompiledRegex(), text)) + return false; + } + // Temp tabs are bound to a single conversation partner — other tells // matching the channel filter must not land here. if (IsTempTab && TellTarget?.IsSet() == true) @@ -525,6 +553,20 @@ public class Tab return true; } + // UI-8: lazily compiles MessageRegex and caches the result. Recompiles only + // when the pattern string changed (validation-key pattern, same idea as the + // _cachedTint* caches). An invalid pattern caches a null regex. + private Regex? GetCompiledRegex() + { + if (_compiledRegexKey != MessageRegex) + { + (_compiledRegex, _) = RegexRouteMatcher.Compile(MessageRegex); + _compiledRegexKey = MessageRegex; + } + + return _compiledRegex; + } + public void AddMessage(Message message, bool unread = true) { Messages.AddPrune(message, MessageManager.MessageDisplayLimit); @@ -579,6 +621,7 @@ public class Tab TellTarget = TellTarget.Clone(), EnableNotificationSound = EnableNotificationSound, NotificationSoundId = NotificationSoundId, + MessageRegex = MessageRegex, IsGreeted = IsGreeted, }; } diff --git a/HellionChat/Ui/SettingsTabs/Tabs.cs b/HellionChat/Ui/SettingsTabs/Tabs.cs index 865c5a6..08816c9 100755 --- a/HellionChat/Ui/SettingsTabs/Tabs.cs +++ b/HellionChat/Ui/SettingsTabs/Tabs.cs @@ -1,8 +1,10 @@ using Dalamud.Bindings.ImGui; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Interface; +using Dalamud.Interface.Colors; using Dalamud.Interface.Utility.Raii; using FFXIVClientStructs.FFXIV.Client.UI; +using HellionChat._Helpers; using HellionChat.Code; using HellionChat.Resources; using HellionChat.Util; @@ -238,6 +240,25 @@ internal sealed class Tabs : ISettingsTab } } } + // UI-8: optional regex filter for this tab. + var regexBuffer = tab.MessageRegex ?? string.Empty; + if (ImGui.InputText($"{HellionStrings.Settings_Tabs_MessageRegex_Name}##regex-{i}", ref regexBuffer, 256)) + tab.MessageRegex = string.IsNullOrEmpty(regexBuffer) ? null : regexBuffer; + ImGuiUtil.HelpMarker(HellionStrings.Settings_Tabs_MessageRegex_Description); + + // Validity feedback: the settings window is not a hot path, so a + // per-frame Compile is fine. An invalid pattern shows a red line and + // the tab behaves as if no regex were set. + if (!string.IsNullOrEmpty(tab.MessageRegex)) + { + var (_, regexError) = RegexRouteMatcher.Compile(tab.MessageRegex); + if (regexError is not null) + ImGui.TextColored( + ImGuiColors.DalamudRed, + string.Format(HellionStrings.Settings_Tabs_MessageRegex_Invalid, regexError) + ); + } + ImGui.Checkbox(Language.Options_Tabs_PopOut, ref tab.PopOut); if (tab.PopOut) { diff --git a/HellionChat/_Helpers/RegexRouteMatcher.cs b/HellionChat/_Helpers/RegexRouteMatcher.cs new file mode 100644 index 0000000..1555793 --- /dev/null +++ b/HellionChat/_Helpers/RegexRouteMatcher.cs @@ -0,0 +1,55 @@ +using System; +using System.Text.RegularExpressions; + +namespace HellionChat._Helpers; + +// UI-8 pure decision helper for the optional per-tab regex filter. Compiling a +// user pattern can throw (invalid syntax) and matching a pathological pattern +// can hang (catastrophic backtracking), so both are contained here. Kept free +// of Dalamud types so the Build Suite can test compile/match/timeout in +// isolation. +// TEST-MIRROR: ../../../Hellion Build test/Ui/RegexRouteMatcherTests.cs +public static class RegexRouteMatcher +{ + // A match must never block the message-routing hot path. 100ms is far + // beyond any sane pattern yet short enough that a runaway pattern fails + // fast instead of freezing the chat. + public static readonly TimeSpan MatchTimeout = TimeSpan.FromMilliseconds(100); + + // Compiles a user pattern. Null/empty pattern -> (null, null): "no filter". + // Invalid pattern -> (null, errorMessage): the tab then behaves as if no + // regex were set (see IsMatch). + public static (Regex? Regex, string? Error) Compile(string? pattern) + { + if (string.IsNullOrEmpty(pattern)) + return (null, null); + + try + { + var regex = new Regex(pattern, RegexOptions.IgnoreCase, MatchTimeout); + return (regex, null); + } + catch (ArgumentException ex) + { + return (null, ex.Message); + } + } + + // Runs the filter. A null regex (no filter, or an invalid pattern) returns + // true so nothing is filtered out. A timeout on a pathological pattern is + // treated as "no match" rather than a crash. + public static bool IsMatch(Regex? regex, string text) + { + if (regex is null) + return true; + + try + { + return regex.IsMatch(text); + } + catch (RegexMatchTimeoutException) + { + return false; + } + } +}