feat(ui): add optional regex filter per tab

This commit is contained in:
2026-05-22 15:41:48 +02:00
parent d05770fd6d
commit a6e2a75422
3 changed files with 119 additions and 0 deletions
+43
View File
@@ -1,10 +1,13 @@
using System.Collections; using System.Collections;
using System.Linq;
using System.Text.RegularExpressions;
using Dalamud; using Dalamud;
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Configuration; using Dalamud.Configuration;
using Dalamud.Game.ClientState.Keys; using Dalamud.Game.ClientState.Keys;
using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Game.Text.SeStringHandling.Payloads;
using Dalamud.Interface.FontIdentifier; using Dalamud.Interface.FontIdentifier;
using HellionChat._Helpers;
using HellionChat.Code; using HellionChat.Code;
using HellionChat.GameFunctions.Types; using HellionChat.GameFunctions.Types;
using HellionChat.Resources; using HellionChat.Resources;
@@ -459,6 +462,10 @@ public class Tab
public bool EnableNotificationSound; public bool EnableNotificationSound;
public uint NotificationSoundId = 1; 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] [NonSerialized]
public uint Unread; public uint Unread;
@@ -512,11 +519,32 @@ public class Tab
[NonSerialized] [NonSerialized]
internal float _cardHoverAlpha; 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) public bool Matches(Message message)
{ {
if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels)) if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
return false; 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<TextChunk>().Select(c => c.Content)
);
if (!RegexRouteMatcher.IsMatch(GetCompiledRegex(), text))
return false;
}
// Temp tabs are bound to a single conversation partner — other tells // Temp tabs are bound to a single conversation partner — other tells
// matching the channel filter must not land here. // matching the channel filter must not land here.
if (IsTempTab && TellTarget?.IsSet() == true) if (IsTempTab && TellTarget?.IsSet() == true)
@@ -525,6 +553,20 @@ public class Tab
return true; 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) public void AddMessage(Message message, bool unread = true)
{ {
Messages.AddPrune(message, MessageManager.MessageDisplayLimit); Messages.AddPrune(message, MessageManager.MessageDisplayLimit);
@@ -579,6 +621,7 @@ public class Tab
TellTarget = TellTarget.Clone(), TellTarget = TellTarget.Clone(),
EnableNotificationSound = EnableNotificationSound, EnableNotificationSound = EnableNotificationSound,
NotificationSoundId = NotificationSoundId, NotificationSoundId = NotificationSoundId,
MessageRegex = MessageRegex,
IsGreeted = IsGreeted, IsGreeted = IsGreeted,
}; };
} }
+21
View File
@@ -1,8 +1,10 @@
using Dalamud.Bindings.ImGui; using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.SubKinds;
using Dalamud.Interface; using Dalamud.Interface;
using Dalamud.Interface.Colors;
using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Utility.Raii;
using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Client.UI;
using HellionChat._Helpers;
using HellionChat.Code; using HellionChat.Code;
using HellionChat.Resources; using HellionChat.Resources;
using HellionChat.Util; 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); ImGui.Checkbox(Language.Options_Tabs_PopOut, ref tab.PopOut);
if (tab.PopOut) if (tab.PopOut)
{ {
+55
View File
@@ -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;
}
}
}