Files
HellionChat/HellionChat/Configuration.cs
T

1081 lines
39 KiB
C#
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
using HellionChat.Util;
namespace HellionChat;
[Serializable]
public class ConfigKeyBind
{
public ModifierFlag Modifier;
public VirtualKey Key;
public override string ToString()
{
var modString = "";
if (Modifier.HasFlag(ModifierFlag.Ctrl))
modString += Language.Keybind_Modifier_Ctrl + " + ";
if (Modifier.HasFlag(ModifierFlag.Shift))
modString += Language.Keybind_Modifier_Shift + " + ";
if (Modifier.HasFlag(ModifierFlag.Alt))
modString += Language.Keybind_Modifier_Alt + " + ";
return modString + Key.GetFancyName();
}
}
[Serializable]
public class Configuration : IPluginConfiguration
{
private const int LatestVersion = 19;
public int Version { get; set; } = LatestVersion;
// Slug-based; ThemeRegistry resolves the object at runtime.
public string Theme = "hellion-arctic";
// Global window opacity, applied across all themes.
public float WindowOpacity = 0.85f;
// UI-12: background opacity of the main chat window while unfocused.
// WindowOpacity above stays the focused value.
public float WindowOpacityInactive = 0.65f;
// Reserved for future UI toggles; pre-declared to avoid a migration later.
public bool ReduceMotion;
// v1.2.1: default flipped false → true. Compact single-line layout is
// more readable than the card-rows layout introduced in v1.2.0.
public bool UseCompactDensity = true;
// Privacy by Default master switch. Set false to restore upstream behaviour.
public bool PrivacyFilterEnabled = true;
// Empty set means the migration has not run yet — see Plugin.cs v6→v7.
public HashSet<ChatType> PrivacyPersistChannels = [];
// Failsafe for ChatTypes added by future FFXIV patches. New configs default
// to the failsafe via PrivacyDefaults; existing configs keep their saved
// choice because the deserializer overrides this initializer.
public bool PrivacyPersistUnknownChannels = Privacy
.PrivacyDefaults
.DefaultPersistUnknownChannels;
// F3.2: dedup unknown-ChatType warnings so a chatty filter doesn't spam
// the log every frame. NonSerialized so the warning fires once per
// runtime, not once-ever-per-install.
[NonSerialized]
private readonly HashSet<ChatType> _warnedUnknownChannels = new();
public bool IsAllowedForStorage(ChatType type)
{
if (!PrivacyFilterEnabled)
return true;
if (PrivacyPersistChannels.Contains(type))
return true;
// F3.2: log first occurrence of a ChatType the running build doesn't
// recognise — i.e. one a future FFXIV patch may have added. Known
// types the user opted out of are routed through the failsafe
// silently, like before.
if (!Enum.IsDefined(typeof(ChatType), type) && _warnedUnknownChannels.Add(type))
{
Plugin.LogProxy.Warning(
"PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.",
type,
PrivacyPersistUnknownChannels
);
}
return PrivacyPersistUnknownChannels;
}
// Retention master switch defaults to false — plugin will not delete
// history until the user explicitly opts in.
public bool RetentionEnabled;
public int RetentionDefaultDays = 30;
public Dictionary<ChatType, int> RetentionPerChannelDays = [];
public DateTimeOffset RetentionLastRunAt = DateTimeOffset.MinValue;
public bool FirstRunCompleted;
// Tracks which plugin version last surfaced the first-run wizard.
// When the running version is newer than this, Plugin.LoadAsync
// re-opens the wizard once so existing users see major UX reworks
// (e.g. the v1.5.2 multi-step rewrite). Skip path and Finish both
// set FirstRunCompleted = true on close, so the wizard only fires
// once per version bump even if the user dismisses it.
public string WizardLastShownVersion = string.Empty;
public bool UseHellionFont = true;
public bool ShowHonorificTitleInHeader = true;
// v1.4.7 opt-in: renders the Honorific glow outline when the title carries
// a Glow colour. Default OFF — keeps v1.4.6 visuals untouched for users
// who don't care, and dodges the per-frame DrawList overhead on low-end
// hardware. Gradient (Color3 / GradientColourSet) is parsed but rendered
// as the primary Color until a later cycle ports the animation.
public bool ShowHonorificGlow;
public bool EnableAutoTellTabs = true;
public int AutoTellTabsLimit = 15;
public bool AutoTellTabsCompactDisplay;
public int AutoTellTabsHistoryPreload = 20;
// Sidebar width in pixels. Default 44 mirrors the icon-only layout from
// v1.2.0; users can widen up to 160 to fit a section-header line like
// "Active Tells (3)" without truncation.
public int SidebarWidth = 44;
public bool AutoTellTabsShowGreetedToggle;
public bool SeenPopOutInputHint;
public bool PopOutInputEnabled = true;
public bool SeenPopOutHeaderHint;
public bool AutoTellTabsOpenAsPopout;
public int GetRetentionDays(ChatType type)
{
if (RetentionPerChannelDays.TryGetValue(type, out var userOverride))
return userOverride;
if (Privacy.PrivacyDefaults.DefaultRetentionDays.TryGetValue(type, out var specDefault))
return specDefault;
return RetentionDefaultDays;
}
public bool HideChat = true;
public bool HideDuringCutscenes = true;
public bool HideWhenNotLoggedIn = true;
public bool HideWhenUiHidden = true;
public bool HideInLoadingScreens;
public bool HideInBattle;
// v1.2.1: default flipped false → true for consistency with other hide defaults.
public bool HideInNewGamePlusMenu = true;
public bool HideWhenInactive;
public int InactivityHideTimeout = 10;
public bool InactivityHideActiveDuringBattle = true;
[Obsolete("Use InactivityHideChannelsV2 instead")]
public Dictionary<ChatType, ChatSource> InactivityHideChannels = [];
public Dictionary<ChatType, (ChatSource, ChatSource)> InactivityHideChannelsV2 = [];
public bool InactivityHideExtraChatAll = true;
public HashSet<Guid> InactivityHideExtraChatChannels = [];
public bool ShowHideButton = true;
public bool NativeItemTooltips = true;
public bool PrettierTimestamps = true;
public bool MoreCompactPretty;
public bool HideSameTimestamps = true;
public bool ShowNoviceNetwork;
public bool SidebarTabView = true;
public bool PrintChangelog = true;
public bool OnlyPreviewIf;
public int PreviewMinimum = 1;
public PreviewPosition PreviewPosition = PreviewPosition.Inside;
public CommandHelpSide CommandHelpSide = CommandHelpSide.None;
public KeybindMode KeybindMode = KeybindMode.Strict;
public LanguageOverride LanguageOverride = LanguageOverride.None;
public bool CanMove = true;
public bool CanResize = true;
public bool ShowTitleBar = true;
public bool ShowPopOutTitleBar = true;
public bool DatabaseBattleMessages;
public bool LoadPreviousSession;
public bool FilterIncludePreviousSessions;
public bool SortAutoTranslate;
public bool CollapseDuplicateMessages;
public bool CollapseKeepUniqueLinks;
public bool SymbolPickerEnabled = true;
public bool PlaySounds = true;
// AUDIO-1: playback volume (0-1) for the three bundled custom sounds.
public float CustomSoundVolume = 0.5f;
// Toast when a tell the user sent could not be delivered.
public bool NotifyFailedTell = true;
public bool KeepInputFocus = true;
public int MaxLinesToRender = 2_500; // 1-10000
public bool Use24HourClock = true;
public bool ShowEmotes = true;
public HashSet<string> BlockedEmotes = [];
public bool FontsEnabled = true;
public ExtraGlyphRanges ExtraGlyphRanges = 0;
public float FontSizeV2 = 12.75f;
public float SymbolsFontSizeV2 = 12.75f;
public SingleFontSpec GlobalFontV2 = new()
{
// dalamud only ships KR as regular, which chat2 used previously for global fonts
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
SizePt = 12.75f,
};
public SingleFontSpec JapaneseFontV2 = new()
{
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkMedium),
SizePt = 12.75f,
};
public bool ItalicEnabled;
public SingleFontSpec ItalicFontV2 = new()
{
FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular),
SizePt = 12.75f,
};
public float TooltipOffset;
public Dictionary<ChatType, uint> ChatColours = BuildDefaultChatColours();
private static Dictionary<ChatType, uint> BuildDefaultChatColours()
{
var defaults = new Dictionary<ChatType, uint>();
foreach (
var (channel, colour) in HellionChat.Resources.ChatColourPresets.All["Hellion"].Colours
)
defaults[channel] = colour;
return defaults;
}
public bool ColorSelectedInputChannelButton = true;
public List<Tab> Tabs = [];
public ConfigKeyBind? ChatTabForward;
public ConfigKeyBind? ChatTabBackward;
public void UpdateFrom(Configuration other, bool backToOriginal)
{
if (backToOriginal)
foreach (var tab in Tabs.Where(t => t.PopOut))
tab.PopOut = false;
HideChat = other.HideChat;
HideDuringCutscenes = other.HideDuringCutscenes;
HideWhenNotLoggedIn = other.HideWhenNotLoggedIn;
HideWhenUiHidden = other.HideWhenUiHidden;
HideInLoadingScreens = other.HideInLoadingScreens;
HideInBattle = other.HideInBattle;
HideInNewGamePlusMenu = other.HideInNewGamePlusMenu;
HideWhenInactive = other.HideWhenInactive;
InactivityHideTimeout = other.InactivityHideTimeout;
InactivityHideActiveDuringBattle = other.InactivityHideActiveDuringBattle;
InactivityHideChannelsV2 = other.InactivityHideChannelsV2.ToDictionary(
pair => pair.Key,
pair => pair.Value
);
InactivityHideExtraChatAll = other.InactivityHideExtraChatAll;
InactivityHideExtraChatChannels = other.InactivityHideExtraChatChannels.ToHashSet();
ShowHideButton = other.ShowHideButton;
NativeItemTooltips = other.NativeItemTooltips;
PrettierTimestamps = other.PrettierTimestamps;
MoreCompactPretty = other.MoreCompactPretty;
HideSameTimestamps = other.HideSameTimestamps;
ShowNoviceNetwork = other.ShowNoviceNetwork;
SidebarTabView = other.SidebarTabView;
PrintChangelog = other.PrintChangelog;
OnlyPreviewIf = other.OnlyPreviewIf;
PreviewMinimum = other.PreviewMinimum;
PreviewPosition = other.PreviewPosition;
CommandHelpSide = other.CommandHelpSide;
KeybindMode = other.KeybindMode;
LanguageOverride = other.LanguageOverride;
CanMove = other.CanMove;
CanResize = other.CanResize;
ShowTitleBar = other.ShowTitleBar;
ShowPopOutTitleBar = other.ShowPopOutTitleBar;
DatabaseBattleMessages = other.DatabaseBattleMessages;
LoadPreviousSession = other.LoadPreviousSession;
FilterIncludePreviousSessions = other.FilterIncludePreviousSessions;
SortAutoTranslate = other.SortAutoTranslate;
CollapseDuplicateMessages = other.CollapseDuplicateMessages;
CollapseKeepUniqueLinks = other.CollapseKeepUniqueLinks;
SymbolPickerEnabled = other.SymbolPickerEnabled;
PlaySounds = other.PlaySounds;
CustomSoundVolume = other.CustomSoundVolume;
NotifyFailedTell = other.NotifyFailedTell;
KeepInputFocus = other.KeepInputFocus;
MaxLinesToRender = other.MaxLinesToRender;
Use24HourClock = other.Use24HourClock;
ShowEmotes = other.ShowEmotes;
// Deep-copy so settings window edits don't leak into live config before Save.
BlockedEmotes = new HashSet<string>(other.BlockedEmotes);
FontsEnabled = other.FontsEnabled;
ItalicEnabled = other.ItalicEnabled;
ExtraGlyphRanges = other.ExtraGlyphRanges;
FontSizeV2 = other.FontSizeV2;
GlobalFontV2 = other.GlobalFontV2;
JapaneseFontV2 = other.JapaneseFontV2;
ItalicFontV2 = other.ItalicFontV2;
SymbolsFontSizeV2 = other.SymbolsFontSizeV2;
TooltipOffset = other.TooltipOffset;
ChatColours = other.ChatColours.ToDictionary(entry => entry.Key, entry => entry.Value);
ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton;
// Keep live temp tabs alive across UpdateFrom — a settings save must
// not destroy open tell conversations. Pinned TempTabs are persistent
// and come through `other` like regular tabs; unpinned TempTabs are
// session-only and held from the local state. For persistent tabs
// (incl. pinned), capture live runtime state by Identifier and restore
// it onto the freshly cloned tabs — CurrentChannel is critical because
// the user may have switched channel in-game between settings-open
// and settings-save, and we'd otherwise overwrite that with the
// settings-time snapshot.
var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t))
.ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread, t.CurrentChannel));
Tabs = other
.Tabs.Where(t => !t.IsTempTab || t.IsPinned)
.Select(t =>
{
var clone = t.Clone();
if (livePersistentSession.TryGetValue(clone.Identifier, out var live))
{
clone.Messages = live.Messages;
clone.LastSendUnread = live.LastSendUnread;
clone.CurrentChannel = live.CurrentChannel;
}
return clone;
})
.ToList();
Tabs.AddRange(liveUnpinnedTempTabs);
ChatTabForward = other.ChatTabForward;
ChatTabBackward = other.ChatTabBackward;
PrivacyFilterEnabled = other.PrivacyFilterEnabled;
PrivacyPersistChannels = [.. other.PrivacyPersistChannels];
PrivacyPersistUnknownChannels = other.PrivacyPersistUnknownChannels;
RetentionEnabled = other.RetentionEnabled;
RetentionDefaultDays = other.RetentionDefaultDays;
RetentionPerChannelDays = other.RetentionPerChannelDays.ToDictionary(
p => p.Key,
p => p.Value
);
RetentionLastRunAt = other.RetentionLastRunAt;
FirstRunCompleted = other.FirstRunCompleted;
WizardLastShownVersion = other.WizardLastShownVersion;
UseHellionFont = other.UseHellionFont;
ShowHonorificTitleInHeader = other.ShowHonorificTitleInHeader;
ShowHonorificGlow = other.ShowHonorificGlow;
// v1.1.0 theme engine fields
Theme = other.Theme;
WindowOpacity = other.WindowOpacity;
WindowOpacityInactive = other.WindowOpacityInactive;
ReduceMotion = other.ReduceMotion;
UseCompactDensity = other.UseCompactDensity;
EnableAutoTellTabs = other.EnableAutoTellTabs;
AutoTellTabsLimit = other.AutoTellTabsLimit;
AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload;
SidebarWidth = other.SidebarWidth;
AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle;
SeenPopOutInputHint = other.SeenPopOutInputHint;
PopOutInputEnabled = other.PopOutInputEnabled;
SeenPopOutHeaderHint = other.SeenPopOutHeaderHint;
AutoTellTabsOpenAsPopout = other.AutoTellTabsOpenAsPopout;
}
}
[Serializable]
public enum UnreadMode
{
All,
Unseen,
None,
}
public static class UnreadModeExt
{
internal static string Name(this UnreadMode mode) =>
mode switch
{
UnreadMode.All => Language.UnreadMode_All,
UnreadMode.Unseen => Language.UnreadMode_Unseen,
UnreadMode.None => Language.UnreadMode_None,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
internal static string? Tooltip(this UnreadMode mode) =>
mode switch
{
UnreadMode.All => Language.UnreadMode_All_Tooltip,
UnreadMode.Unseen => Language.UnreadMode_Unseen_Tooltip,
UnreadMode.None => Language.UnreadMode_None_Tooltip,
_ => null,
};
}
[Serializable]
public class Tab
{
public string Name = Language.Tab_DefaultName;
// Optional FontAwesome glyph name; null falls back to TabIconMapping default.
public string? Icon = null;
[Obsolete("Removed in favor of SelectedChannels")]
public Dictionary<ChatType, ChatSource> ChatCodes = new();
public Dictionary<ChatType, (ChatSource, ChatSource)> SelectedChannels = new();
public bool ExtraChatAll;
public HashSet<Guid> ExtraChatChannels = [];
public UnreadMode UnreadMode = UnreadMode.Unseen;
public bool UnhideOnActivity;
public bool DisplayTimestamp = true;
public InputChannel? Channel;
public bool PopOut;
public bool IndependentOpacity;
public float Opacity = 100f;
public bool InputDisabled;
public bool CanMove = true;
public bool CanResize = true;
public bool IndependentHide;
public bool HideDuringCutscenes = true;
public bool HideWhenNotLoggedIn = true;
public bool HideWhenUiHidden = true;
public bool HideInLoadingScreens;
public bool HideInBattle;
public bool HideWhenInactive;
public bool IsTempTab;
// Pinned TempTabs survive plugin reload and logout — tester feedback from
// Jin (v1.4.7). Pinned tabs live in their own pool (MaxPinnedTempTabs)
// separate from the AutoTellTabsLimit bucket.
public bool IsPinned;
public bool AllSenderMessages;
public TellTarget TellTarget = TellTarget.Empty();
// Per-tab notification sound for messages arriving in an inactive 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;
[NonSerialized]
public uint LastSendUnread;
[NonSerialized]
public long LastActivity;
[NonSerialized]
public MessageList Messages = new();
[NonSerialized]
public UsedChannel CurrentChannel = new();
[NonSerialized]
public Guid Identifier = Guid.NewGuid();
// Session-only greeted flag for club-greeter workflows.
[NonSerialized]
public bool IsGreeted;
// Separate validation keys per cache so TellTarget changes don't
// cause GetTint and GetIcon to strand each other with stale entries.
[NonSerialized]
internal string? _cachedTintTellName;
[NonSerialized]
internal uint _cachedTintTellWorld;
[NonSerialized]
internal uint _cachedTellTint;
[NonSerialized]
internal string? _cachedIconTellName;
[NonSerialized]
internal uint _cachedIconTellWorld;
[NonSerialized]
internal string? _cachedTellIcon;
// PM-3 hover-lerp state. Default 0f means "not hovered". Sidebar
// path animates per tab; card-mode-border path is tab-aggregate
// (any card-row hover ramps the alpha for all cards in this tab).
// Lerp speed lives in the render loop, not here, so the same field
// serves both sites at the same animation curve.
[NonSerialized]
internal float _hoverAlpha;
[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<TextChunk>().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)
return ChunkUtil.MatchesSender(message, TellTarget.Name, TellTarget.World);
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);
if (!unread)
return;
Unread += 1;
if (
message.Matches(
Plugin.Config.InactivityHideChannelsV2,
Plugin.Config.InactivityHideExtraChatAll,
Plugin.Config.InactivityHideExtraChatChannels
)
)
LastActivity = Environment.TickCount64;
}
public void Clear() => Messages.Clear();
public Tab Clone()
{
return new Tab
{
Name = Name,
SelectedChannels = SelectedChannels.ToDictionary(pair => pair.Key, pair => pair.Value),
ExtraChatAll = ExtraChatAll,
ExtraChatChannels = ExtraChatChannels.ToHashSet(),
UnreadMode = UnreadMode,
UnhideOnActivity = UnhideOnActivity,
Unread = Unread,
LastActivity = LastActivity,
DisplayTimestamp = DisplayTimestamp,
Channel = Channel,
PopOut = PopOut,
IndependentOpacity = IndependentOpacity,
Opacity = Opacity,
Identifier = Identifier,
InputDisabled = InputDisabled,
CurrentChannel = CurrentChannel.Clone(),
CanMove = CanMove,
CanResize = CanResize,
IndependentHide = IndependentHide,
HideDuringCutscenes = HideDuringCutscenes,
HideWhenNotLoggedIn = HideWhenNotLoggedIn,
HideWhenUiHidden = HideWhenUiHidden,
HideInLoadingScreens = HideInLoadingScreens,
HideInBattle = HideInBattle,
HideWhenInactive = HideWhenInactive,
IsTempTab = IsTempTab,
IsPinned = IsPinned,
AllSenderMessages = AllSenderMessages,
TellTarget = TellTarget.Clone(),
EnableNotificationSound = EnableNotificationSound,
NotificationSoundId = NotificationSoundId,
MessageRegex = MessageRegex,
IsGreeted = IsGreeted,
};
}
/// Ordered message list with duplicate ID tracking, sorting and mutex protection.
public class MessageList
{
private readonly SemaphoreSlim LockSlim = new(1, 1);
private readonly List<Message> Messages;
private readonly HashSet<Guid> TrackedMessageIds;
public MessageList()
{
Messages = [];
TrackedMessageIds = [];
}
public MessageList(int initialCapacity)
{
Messages = new List<Message>(initialCapacity);
TrackedMessageIds = new HashSet<Guid>(initialCapacity);
}
public void AddPrune(Message message, int max)
{
LockSlim.Wait(-1);
try
{
AddLocked(message);
PruneMaxLocked(max);
}
finally
{
LockSlim.Release();
}
}
public void AddSortPrune(IEnumerable<Message> messages, int max)
{
LockSlim.Wait(-1);
try
{
foreach (var message in messages)
AddLocked(message);
SortLocked();
PruneMaxLocked(max);
}
finally
{
LockSlim.Release();
}
}
private void AddLocked(Message message)
{
if (TrackedMessageIds.Contains(message.Id))
return;
Messages.Add(message);
TrackedMessageIds.Add(message.Id);
}
public void Clear()
{
LockSlim.Wait(-1);
try
{
Messages.Clear();
TrackedMessageIds.Clear();
}
finally
{
LockSlim.Release();
}
}
private void SortLocked()
{
Messages.Sort((a, b) => a.Date.CompareTo(b.Date));
}
private void PruneMaxLocked(int max)
{
while (Messages.Count > max)
{
TrackedMessageIds.Remove(Messages[0].Id);
Messages.RemoveAt(0);
}
}
/// Current message count. Lock-per-read is acceptable for 1×/sec status bar polling.
public int Count
{
get
{
LockSlim.Wait(-1);
try
{
return Messages.Count;
}
finally
{
LockSlim.Release();
}
}
}
/// Returns an array copy of the message list for usage outside of main thread.
public async Task<Message[]> GetCopy(int millisecondsTimeout = -1)
{
await LockSlim.WaitAsync(millisecondsTimeout);
try
{
return Messages.ToArray();
}
finally
{
LockSlim.Release();
}
}
/// Returns a read-only list while holding a reader lock. Use with a using statement.
public RLockedMessageList GetReadOnly(int millisecondsTimeout = -1)
{
LockSlim.Wait(millisecondsTimeout);
return new RLockedMessageList(LockSlim, Messages);
}
public class RLockedMessageList(SemaphoreSlim lockSlim, List<Message> messages)
: IReadOnlyList<Message>,
IDisposable
{
public IEnumerator<Message> GetEnumerator()
{
return messages.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
public int Count => messages.Count;
public Message this[int index] => messages[index];
public void Dispose()
{
lockSlim.Release();
}
}
}
}
public class UsedChannel
{
public InputChannel Channel = InputChannel.Invalid;
public List<Chunk> Name = [];
public TellTarget? TellTarget;
public bool UseTempChannel;
public InputChannel TempChannel = InputChannel.Invalid;
public TellTarget? TempTellTarget;
public void ResetTempChannel()
{
UseTempChannel = false;
TempTellTarget = null;
TempChannel = InputChannel.Invalid;
}
public void SetChannel(InputChannel channel)
{
Channel = channel;
}
// ---------------------------------------------------------------
// Cherry-picked from ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12)
// - Deep-clone the UsedChannel so Tab.Clone() no longer shares
// channel state (incl. TellTarget) with its origin Tab. Previously
// a reference copy: PopOut and Temp tabs mutated each other.
// - Name is intentionally a reference copy (matches upstream); it
// gets reassigned on every channel switch anyway.
// TEST-MIRROR: ../../Hellion Build test/_Helpers/UsedChannelCloneTests.cs
// ---------------------------------------------------------------
public UsedChannel Clone()
{
return new UsedChannel
{
Channel = Channel,
Name = Name,
TellTarget = TellTarget?.Clone(),
UseTempChannel = UseTempChannel,
TempChannel = TempChannel,
TempTellTarget = TempTellTarget?.Clone(),
};
}
}
[Serializable]
public enum PreviewPosition
{
None,
Inside,
Top,
Bottom,
Tooltip,
}
public static class PreviewPositionExt
{
public static string Name(this PreviewPosition position) =>
position switch
{
PreviewPosition.None => Language.Options_Preview_None,
PreviewPosition.Inside => Language.Options_Preview_Inside,
PreviewPosition.Top => Language.Options_Preview_Top,
PreviewPosition.Bottom => Language.Options_Preview_Bottom,
PreviewPosition.Tooltip => Language.Options_Preview_Tooltip,
_ => throw new ArgumentOutOfRangeException(nameof(position), position, null),
};
}
[Serializable]
public enum CommandHelpSide
{
None,
Left,
Right,
}
public static class CommandHelpSideExt
{
public static string Name(this CommandHelpSide side) =>
side switch
{
CommandHelpSide.None => Language.CommandHelpSide_None,
CommandHelpSide.Left => Language.CommandHelpSide_Left,
CommandHelpSide.Right => Language.CommandHelpSide_Right,
_ => throw new ArgumentOutOfRangeException(nameof(side), side, null),
};
}
[Serializable]
public enum KeybindMode
{
Flexible,
Strict,
}
public static class KeybindModeExt
{
public static string Name(this KeybindMode mode) =>
mode switch
{
KeybindMode.Flexible => Language.KeybindMode_Flexible_Name,
KeybindMode.Strict => Language.KeybindMode_Strict_Name,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
public static string? Tooltip(this KeybindMode mode) =>
mode switch
{
KeybindMode.Flexible => Language.KeybindMode_Flexible_Tooltip,
KeybindMode.Strict => Language.KeybindMode_Strict_Tooltip,
_ => null,
};
}
[Serializable]
public enum LanguageOverride
{
None,
ChineseSimplified,
ChineseTraditional,
Dutch,
English,
French,
German,
Greek,
Japanese,
PortugueseBrazil,
Romanian,
Russian,
Spanish,
Swedish,
// v1.5.3: Crowdin-heritage activated and Forge-maintained additions.
// Append-only to preserve serialized integer values of existing user configs.
Italian,
Korean,
Norwegian,
Catalan,
Czech,
Danish,
Finnish,
Hungarian,
Polish,
PortuguesePortugal,
Turkish,
Ukrainian,
}
public static class LanguageOverrideExt
{
public static string Name(this LanguageOverride mode) =>
mode switch
{
LanguageOverride.None => Language.LanguageOverride_None,
LanguageOverride.ChineseSimplified => "简体中文",
LanguageOverride.ChineseTraditional => "繁體中文",
LanguageOverride.Dutch => "Nederlands",
LanguageOverride.English => "English",
LanguageOverride.French => "Français",
LanguageOverride.German => "Deutsch",
LanguageOverride.Greek => "Ελληνικά",
LanguageOverride.Italian => "Italiano",
LanguageOverride.Japanese => "日本語",
LanguageOverride.Korean => "한국어",
LanguageOverride.Norwegian => "Norsk bokmål",
LanguageOverride.PortugueseBrazil => "Português do Brasil",
LanguageOverride.Romanian => "Română",
LanguageOverride.Russian => "Русский",
LanguageOverride.Spanish => "Español",
LanguageOverride.Swedish => "Svenska",
LanguageOverride.Catalan => "Català",
LanguageOverride.Czech => "Čeština",
LanguageOverride.Danish => "Dansk",
LanguageOverride.Finnish => "Suomi",
LanguageOverride.Hungarian => "Magyar",
LanguageOverride.Polish => "Polski",
LanguageOverride.PortuguesePortugal => "Português (Portugal)",
LanguageOverride.Turkish => "Türkçe",
LanguageOverride.Ukrainian => "Українська",
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
public static string Code(this LanguageOverride mode) =>
mode switch
{
LanguageOverride.None => "",
LanguageOverride.ChineseSimplified => "zh-hans",
LanguageOverride.ChineseTraditional => "zh-hant",
LanguageOverride.Dutch => "nl",
LanguageOverride.English => "en",
LanguageOverride.French => "fr",
LanguageOverride.German => "de",
LanguageOverride.Greek => "el",
LanguageOverride.Italian => "it",
LanguageOverride.Japanese => "ja",
LanguageOverride.Korean => "ko",
LanguageOverride.Norwegian => "nb",
LanguageOverride.PortugueseBrazil => "pt-br",
LanguageOverride.Romanian => "ro",
LanguageOverride.Russian => "ru",
LanguageOverride.Spanish => "es",
LanguageOverride.Swedish => "sv",
LanguageOverride.Catalan => "ca",
LanguageOverride.Czech => "cs",
LanguageOverride.Danish => "da",
LanguageOverride.Finnish => "fi",
LanguageOverride.Hungarian => "hu",
LanguageOverride.Polish => "pl",
LanguageOverride.PortuguesePortugal => "pt-pt",
LanguageOverride.Turkish => "tr",
LanguageOverride.Ukrainian => "uk",
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
// Maps a language to the ExtraGlyphRanges flag required for full UI
// rendering in that locale. The settings save path ORs this into
// Mutable.ExtraGlyphRanges so users do not need to know which range
// to tick manually. Returns 0 for locales fully covered by the default
// ImGui glyph range (Latin-1) or by the separate Japanese font handle.
public static ExtraGlyphRanges RequiredGlyphRanges(this LanguageOverride mode) =>
mode switch
{
LanguageOverride.Korean => ExtraGlyphRanges.Korean,
LanguageOverride.ChineseSimplified => ExtraGlyphRanges.ChineseSimplifiedCommon,
LanguageOverride.ChineseTraditional => ExtraGlyphRanges.ChineseFull,
LanguageOverride.Ukrainian => ExtraGlyphRanges.Cyrillic,
LanguageOverride.Greek => ExtraGlyphRanges.Greek,
LanguageOverride.Czech
or LanguageOverride.Polish
or LanguageOverride.Romanian
or LanguageOverride.Hungarian
or LanguageOverride.Turkish => ExtraGlyphRanges.LatinExtended,
_ => 0,
};
}
[Serializable]
[Flags]
public enum ExtraGlyphRanges
{
ChineseFull = 1 << 0,
ChineseSimplifiedCommon = 1 << 1,
Cyrillic = 1 << 2,
Japanese = 1 << 3,
Korean = 1 << 4,
Thai = 1 << 5,
Vietnamese = 1 << 6,
// v1.5.3: Custom ranges for languages with Latin Extended-A glyphs (Czech,
// Polish, Romanian, Turkish, Hungarian) and Greek polytonic accents.
LatinExtended = 1 << 7,
Greek = 1 << 8,
}
public static class ExtraGlyphRangesExt
{
// Custom (start, end) inclusive pair lists for ranges that ImGui does
// not ship a built-in helper for. SetUpRanges() feeds these into
// ImFontGlyphRangesBuilder.AddChar via the `chars` parameter of
// BuildRange so we avoid the lifetime/pinning question that the native
// GetGlyphRanges*-pointer pathway papers over.
internal static readonly ushort[] LatinExtendedPairs = { 0x0100, 0x024F };
internal static readonly ushort[] GreekPairs = { 0x0370, 0x03FF, 0x1F00, 0x1FFF };
public static string Name(this ExtraGlyphRanges ranges) =>
ranges switch
{
ExtraGlyphRanges.ChineseFull => Language.ExtraGlyphRanges_ChineseFull_Name,
ExtraGlyphRanges.ChineseSimplifiedCommon =>
Language.ExtraGlyphRanges_ChineseSimplifiedCommon_Name,
ExtraGlyphRanges.Cyrillic => Language.ExtraGlyphRanges_Cyrillic_Name,
ExtraGlyphRanges.Japanese => Language.ExtraGlyphRanges_Japanese_Name,
ExtraGlyphRanges.Korean => Language.ExtraGlyphRanges_Korean_Name,
ExtraGlyphRanges.Thai => Language.ExtraGlyphRanges_Thai_Name,
ExtraGlyphRanges.Vietnamese => Language.ExtraGlyphRanges_Vietnamese_Name,
ExtraGlyphRanges.LatinExtended => Language.ExtraGlyphRanges_LatinExtended_Name,
ExtraGlyphRanges.Greek => Language.ExtraGlyphRanges_Greek_Name,
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
};
public static unsafe nint Range(this ExtraGlyphRanges ranges) =>
ranges switch
{
ExtraGlyphRanges.ChineseFull => (nint)ImGui.GetIO().Fonts.GetGlyphRangesChineseFull(),
ExtraGlyphRanges.ChineseSimplifiedCommon => (nint)
ImGui.GetIO().Fonts.GetGlyphRangesChineseSimplifiedCommon(),
ExtraGlyphRanges.Cyrillic => (nint)ImGui.GetIO().Fonts.GetGlyphRangesCyrillic(),
ExtraGlyphRanges.Japanese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesJapanese(),
ExtraGlyphRanges.Korean => (nint)ImGui.GetIO().Fonts.GetGlyphRangesKorean(),
ExtraGlyphRanges.Thai => (nint)ImGui.GetIO().Fonts.GetGlyphRangesThai(),
ExtraGlyphRanges.Vietnamese => (nint)ImGui.GetIO().Fonts.GetGlyphRangesVietnamese(),
// LatinExtended and Greek are applied via builder.AddChar in
// FontManager.SetUpRanges, not through a native pointer range.
ExtraGlyphRanges.LatinExtended => 0,
ExtraGlyphRanges.Greek => 0,
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
};
}