feat(ui): add optional regex filter per tab
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user