Files
HellionChat/HellionChat/Configuration.cs
T
JonKazama-Hellion fd5f970a8b feat(tabs): add IsPinned with separate pool and 5-tab cap
Tester-Request from Jin (2026-05-03): TempTabs should be pinnable so a
key conversation partner survives a relog. Right-click a TempTab and
choose Pin Tab / Unpin Tab / Promote to permanent.

Pool semantics:
- AutoTellTabsLimit (15) still gates the auto-managed unpinned pool.
- Pinned TempTabs live in their own pool, hard-capped at 5.
- The 6th pin attempt fails with a notification; users can unpin first
  or promote to permanent.
- Unpinning into a full unpinned pool drops the oldest unpinned (no
  user friction).

Mechanics:
- Tab.IsPinned (default false); Tab.Clone() carries it.
- Migration v16 -> v17 (additive; existing tabs default to unpinned).
- Three strip-sites synchronised through TabLifecycleHelpers:
  Plugin.cs load-time, Plugin.SaveConfig, Configuration.UpdateFrom.
- AutoTellTabsService:
  * MaxPinnedTempTabs constant.
  * F2.1 _activeTempTabCount counter retired — ActiveTempTabCount is
    now Tabs.Count(predicate). Pin/Unpin/Promote transitions are
    cold-path and don't need lock-free reads.
  * DropOldestTempTab filters on IsInUnpinnedPool so pinned tabs are
    never drop candidates.
  * OnLogout strips only the unpinned pool; pinned popouts and the
    active-tab switch behave correspondingly.
  * TryPin / Unpin / PromoteToPermanent service methods.
- ChatLogWindow tab context menu: Pin / Unpin / Promote with disabled-
  state at-cap tooltip + Promote tooltip explaining the channel-filter
  side effect.
- HellionStrings (EN+DE) for menu labels, tooltips, the limit warning.
- AutoTellTabsLimit slider description now flags the separate pinned
  pool so users aren't surprised by 18 tabs when the limit reads 15.
2026-05-13 09:06:14 +02:00

911 lines
31 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 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.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 = 17;
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;
// 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;
public bool UseHellionFont = true;
public bool ShowHonorificTitleInHeader = true;
public bool EnableAutoTellTabs = true;
public int AutoTellTabsLimit = 15;
public bool AutoTellTabsCompactDisplay;
public int AutoTellTabsHistoryPreload = 20;
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 PlaySounds = 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;
PlaySounds = other.PlaySounds;
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 MessageList + LastSendUnread by
// Identifier and restore them onto the freshly cloned tabs.
var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t))
.ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread));
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;
}
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;
UseHellionFont = other.UseHellionFont;
ShowHonorificTitleInHeader = other.ShowHonorificTitleInHeader;
// v1.1.0 theme engine fields
Theme = other.Theme;
WindowOpacity = other.WindowOpacity;
ReduceMotion = other.ReduceMotion;
UseCompactDensity = other.UseCompactDensity;
EnableAutoTellTabs = other.EnableAutoTellTabs;
AutoTellTabsLimit = other.AutoTellTabsLimit;
AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload;
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();
[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;
public bool Matches(Message message)
{
if (!message.Matches(SelectedChannels, ExtraChatAll, ExtraChatChannels))
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;
}
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(),
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,
// Italian,
Japanese,
// Korean,
// Norwegian,
PortugueseBrazil,
Romanian,
Russian,
Spanish,
Swedish,
}
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 => "한국어 (Korean)",
// LanguageOverride.Norwegian => "Norsk",
LanguageOverride.PortugueseBrazil => "Português do Brasil",
LanguageOverride.Romanian => "Română",
LanguageOverride.Russian => "Русский",
LanguageOverride.Spanish => "Español",
LanguageOverride.Swedish => "Svenska",
_ => 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 => "no",
LanguageOverride.PortugueseBrazil => "pt-br",
LanguageOverride.Romanian => "ro",
LanguageOverride.Russian => "ru",
LanguageOverride.Spanish => "es",
LanguageOverride.Swedish => "sv",
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
}
[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,
}
public static class ExtraGlyphRangesExt
{
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,
_ => 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(),
_ => throw new ArgumentOutOfRangeException(nameof(ranges), ranges, null),
};
}