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 = 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; // 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 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 _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 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 InactivityHideChannels = []; public Dictionary InactivityHideChannelsV2 = []; public bool InactivityHideExtraChatAll = true; public HashSet 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; // 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 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 ChatColours = BuildDefaultChatColours(); private static Dictionary BuildDefaultChatColours() { var defaults = new Dictionary(); foreach ( var (channel, colour) in HellionChat.Resources.ChatColourPresets.All["Hellion"].Colours ) defaults[channel] = colour; return defaults; } public bool ColorSelectedInputChannelButton = true; public List 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; 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(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; 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 ChatCodes = new(); public Dictionary SelectedChannels = new(); public bool ExtraChatAll; public HashSet 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; [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; 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(), EnableNotificationSound = EnableNotificationSound, NotificationSoundId = NotificationSoundId, 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 Messages; private readonly HashSet TrackedMessageIds; public MessageList() { Messages = []; TrackedMessageIds = []; } public MessageList(int initialCapacity) { Messages = new List(initialCapacity); TrackedMessageIds = new HashSet(initialCapacity); } public void AddPrune(Message message, int max) { LockSlim.Wait(-1); try { AddLocked(message); PruneMaxLocked(max); } finally { LockSlim.Release(); } } public void AddSortPrune(IEnumerable 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 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 messages) : IReadOnlyList, IDisposable { public IEnumerator 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 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), }; }