using System.Collections; using System.Linq; 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 { internal const int LatestVersion = 28; 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; // Background opacity of the main chat window while unfocused. // WindowOpacity above stays the focused value. public float WindowOpacityInactive = 0.75f; // 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; // Privacy by Default master switch. Set false to restore upstream behaviour. public bool PrivacyFilterEnabled = true; // Stays empty here. Dalamud deserialises with Json.NET's default settings, // which means ObjectCreationHandling.Auto: a collection field that already // holds items is *populated*, not replaced. A non-empty initializer would // therefore union itself into every config on load and switch channels the // user had unticked back on. Verified against Newtonsoft 13.0.3: // saved [] loads as the initializer, saved [Say] loads as initializer + Say. // // Privacy by Default (DSGVO Art. 25) is seeded in CreateFresh instead, which // only runs when there is no config file at all. 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; // 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(); // A first-ever start records the player's own conversations and nothing // else. Deliberately not a field initializer -- see PrivacyPersistChannels. internal static Configuration CreateFresh() { var config = new Configuration(); config.PrivacyPersistChannels = [.. Privacy.PrivacyDefaults.PrivacyFirstWhitelist]; return config; } public bool IsAllowedForStorage(ChatType type) { if (!PrivacyFilterEnabled) return true; // Runs per message on the worker thread while the settings UI can Add to the // same set from the draw thread. A HashSet.Contains racing an Add that // resizes buckets can return the wrong answer -- and this answer decides // whether a message is persisted. Lock kept tight, this is a hot path. bool listed; lock (Plugin.Instance.ConfigMapsLock) listed = PrivacyPersistChannels.Contains(type); var known = Enum.IsDefined(typeof(ChatType), type); // Log the first occurrence of a ChatType the running build doesn't // recognise — i.e. one a future FFXIV patch may have added. if (!known && !listed && _warnedUnknownChannels.Add(type)) { Plugin.LogProxy.Warning( "PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.", type, PrivacyPersistUnknownChannels ); } return Privacy.StorageRule.Allows(listed, known, 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 = true; public bool EnableAutoTellTabs = true; public int AutoTellTabsLimit = 15; public bool AutoTellTabsCompactDisplay = true; public int AutoTellTabsHistoryPreload = 100; // Expanded sidebar width in pixels. 44 was carried over from the v1.2.0 // icon-only layout and stayed the default long after the sidebar started // drawing labels beside those icons, so every tab name came out clipped -- // it only went unnoticed because everyone had widened it by hand. 160 fits // the German tab names, which are the longest of the 25 languages, and the // floor below is set where they stop being readable rather than where the // icons stop fitting. public int SidebarWidth = 160; public bool AutoTellTabsShowGreetedToggle; public bool SeenPopOutInputHint; public bool PopOutInputEnabled = true; public bool SeenPopOutHeaderHint; // On by default: the wizard's closing step tells the user to try /tell and // watch a conversation open on its own, so the behaviour it describes has to // be the behaviour they get. // Retired in v2.0.5: this and TellAutoOpenMode were two settings for one // decision, and this one always won because it fired first -- which made the // other one look broken. The FIELD stays so the v28 migration can still read // what the user actually had; nothing else reads it, and it is gone from the // settings window. public bool AutoTellTabsOpenAsPopout = true; // How sender names are rendered in the chat log. public WorldSuffixMode WorldSuffixMode = WorldSuffixMode.OtherWorldOnly; public NameFormMode NameFormMode = NameFormMode.Full; 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 bool ShowHideButton = true; public bool NativeItemTooltips = true; public bool ScreenshotMode; // No control and no reader. Kept so a stored value survives until the // rendering they describe exists; see the reconnect backlog. Note the two // resource sets disagree on what PrettierTimestamps even means -- the wizard // called it "relative time", the settings tab "modern layout". public bool PrettierTimestamps = true; public bool MoreCompactPretty = true; public bool HideSameTimestamps = true; // No reader; see the reconnect backlog. public bool ShowNoviceNetwork; // Migration-only since v23: the 1.5.6 sidebar↔top-tabs switch, superseded by // MainWindowLayoutMode in the v1.6.0 rewrite. No UI control anymore; read by // the v23 migration in Plugin.cs and kept deserializable so a 1.5.6 user's // false value survives one load. Remove in a later schema bump. public bool SidebarTabView = true; // No reader; see the reconnect backlog. public bool PrintChangelog = true; public bool OnlyPreviewIf; public int PreviewMinimum = 1; public PreviewPosition PreviewPosition = PreviewPosition.Inside; public CommandHelpSide CommandHelpSide = CommandHelpSide.Right; public KeybindMode KeybindMode = KeybindMode.Strict; public LanguageOverride LanguageOverride = LanguageOverride.None; public bool CanMove = true; public bool CanResize = true; public bool ShowTitleBar; public bool ShowPopOutTitleBar = true; public bool DatabaseBattleMessages; 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; // Warn before sending a message that carries plugin-only glyphs. public bool NotifyPluginDisclosure = true; public bool KeepInputFocus = true; public bool Use24HourClock = true; 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; } // No reader; see the reconnect backlog. public bool ColorSelectedInputChannelButton = true; public List Tabs = []; public ConfigKeyBind? ChatTabForward; public ConfigKeyBind? ChatTabBackward; // v20 fields: window visibility state, channel popout pool size and // sidebar auto-switch threshold. All initializers double as the // migration defaults for configs loaded at v19 or earlier. // Still written on open/close, but no longer read for the start state: the // window always shows on login (1.5.6 parity, MainWindow ctor). Kept for the // migration round-trip and a possible future "remember session state" opt-in. public bool MainWindowOpen = true; public bool SettingsWindowOpen; public int MaxParallelPopouts = 8; // Popout, not Sidebar: with AutoTellTabsOpenAsPopout defaulting to true, a // fresh install has ALWAYS opened tells in their own window. Naming that as // the default is what keeps a new install behaving the way it always did. public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Popout; // When true (default) the tell-auto-open router switches the active tab to the // incoming tell on every message; when false the tab is still created/revealed // with its unread badge but the active tab is left where the user is reading. public bool TellAutoOpenSwitchAlways = true; public int SidebarAutoSwitchThresholdPx = 800; // v22 field: MainWindow layout mode (sidebar vs. horizontal top tabs). // Initializer doubles as the migration default for configs loaded at v21. public MainWindowLayoutMode MainWindowLayoutMode = MainWindowLayoutMode.Sidebar; } [Serializable] public enum TellAutoOpenMode { Off, Sidebar, TopTab, Popout, } [Serializable] public enum MainWindowLayoutMode { Sidebar, TopTabs, } [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; 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; // Six per-tab hide conditions used to live here. Their reader was the // pop-out window, which stopped consulting them in cf4705e; the equivalents // that survive are the window-level fields of the same name further up this // file, and v1.12.0 gave every one of those a control. // // Per-tab was the wrong unit anyway: "hide during cutscenes" is a statement // about the screen, not about one conversation. // // HideWhenInactive stays -- the auto-tell service writes it. public bool HideWhenInactive; public bool IsTempTab; // Pinned TempTabs survive plugin reload and logout -- tester feedback in // 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(); // Set once, where the name is built from a conversation partner. Never // cleared by promotion, unlike IsTempTab and TellTarget -- both of those are // routing state and are deliberately wiped when a tab is promoted, while the // name they produced stays. Screenshot mode reads this, so it has to outlive // every path that keeps the name but drops the binding. public bool NameCameFromPartner; // 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; // 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; // Copy-on-write for the three channel-filter fields. They are read without // any lock from the pending-message thread, the filter worker and the draw // thread, and until v1.12.0 nothing ever wrote them after load -- so the // tab editor is their first writer, and mutating a live Dictionary while // Matches enumerates it is the classic way to get a wrong answer or an // exception on somebody else's thread. // // Building the replacements and swapping the references means a reader sees // either the old set or the new one, never half of either. // // What this deliberately does not do is make the three writes one atomic // step. A reader can catch the new dictionary with the old ExtraChat flag // for a single message. That is harmless: the editor finishes by clearing // and refiltering every tab, so any message placed by a mixed view is // reconsidered a moment later. Making it truly atomic would mean one // reference for all three, and these three are serialized fields with a // shape the config file already has. internal void ReplaceChannelFilter( Dictionary selected, bool extraChatAll, HashSet extraChatChannels ) { Volatile.Write(ref SelectedChannels, selected); Volatile.Write(ref ExtraChatChannels, extraChatChannels); Volatile.Write(ref ExtraChatAll, extraChatAll); } 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; // Stamped for every message now. The condition that used to sit here // filtered on InactivityHideChannels, a setting for the hide-when- // inactive feature -- and that feature lost its reader in cf4705e. So // which tell tab the auto-tell pool drops first, which is the only // thing that reads this stamp, hung on a setting for something that // does not happen. LastActivity = Environment.TickCount64; } public void Clear() => Messages.Clear(); public Tab Clone() { return new Tab { Name = Name, // Icon feeds the sidebar glyph and a clone round-trip used to drop // it silently. ChatCodes sat beside it until v1.12.0, carrying data // for a migration that the v16 schema gate had already made // unreachable. Icon = Icon, 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, 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. // The same mapping keyed by culture code, for when the language override is // None and the UI follows Dalamud. Without this the ranges only ever get // filled by an explicit language pick -- installs that never touched the // setting rendered their own locale in whatever the default range covers, // which for Korean, Chinese, Cyrillic and Greek is boxes. It went unnoticed // while configs accumulated ranges over time; a fresh config has none. public static ExtraGlyphRanges RequiredGlyphRangesForCulture(string? cultureCode) { var code = (cultureCode ?? string.Empty).ToLowerInvariant(); // Longest first: zh-hant has to win over the zh prefix. if (code.StartsWith("zh-hant") || code.StartsWith("zh-tw") || code.StartsWith("zh-hk")) return ExtraGlyphRanges.ChineseFull; if (code.StartsWith("zh")) return ExtraGlyphRanges.ChineseSimplifiedCommon; if (code.StartsWith("ko")) return ExtraGlyphRanges.Korean; if (code.StartsWith("uk") || code.StartsWith("ru") || code.StartsWith("be")) return ExtraGlyphRanges.Cyrillic; if (code.StartsWith("el")) return ExtraGlyphRanges.Greek; if ( code.StartsWith("cs") || code.StartsWith("pl") || code.StartsWith("ro") || code.StartsWith("hu") || code.StartsWith("tr") ) return ExtraGlyphRanges.LatinExtended; return 0; } 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), }; }