diff --git a/HellionChat/AllCommands.cs b/HellionChat/AllCommands.cs new file mode 100644 index 0000000..30d6d2e --- /dev/null +++ b/HellionChat/AllCommands.cs @@ -0,0 +1,36 @@ +using Lumina.Excel.Sheets; + +namespace HellionChat; + +// Ported 1:1 from v1.5.6 ChatLogWindow.SetUpAllCommands. Provides a fast +// lookup from slash-command string to the game's TextCommand row so the +// InputBar callback can feed descriptions to CommandHelpWindow without +// hitting the sheet on every keystroke. +internal static class AllCommands +{ + private static readonly Dictionary Commands = BuildCommands(); + + private static Dictionary BuildCommands() + { + var dict = new Dictionary(StringComparer.Ordinal); + foreach (var command in Sheets.TextCommandSheet) + { + if (!command.Command.IsEmpty) + dict.TryAdd(command.Command.ToString(), command); + + if (!command.ShortCommand.IsEmpty) + dict.TryAdd(command.ShortCommand.ToString(), command); + + if (!command.Alias.IsEmpty) + dict.TryAdd(command.Alias.ToString(), command); + + if (!command.ShortAlias.IsEmpty) + dict.TryAdd(command.ShortAlias.ToString(), command); + } + + return dict; + } + + public static bool TryGetValue(string command, out TextCommand textCommand) => + Commands.TryGetValue(command, out textCommand); +} diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 6418f99..f66eed5 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -218,7 +218,7 @@ internal sealed class AutoTellTabsService : IDisposable return null; } - private static Tab? FindTempTab(string name, uint world) + internal static Tab? FindTempTab(string name, uint world) { var byTarget = Plugin.Config.Tabs.FirstOrDefault(t => t.IsTempTab @@ -239,6 +239,16 @@ internal sealed class AutoTellTabsService : IDisposable ); } + // Lock-protected lookup for the framework-thread caller (TellRouterService). + // Config.Tabs is mutated under _tempTabsLock on the PendingMessage worker thread, + // so a framework-tick reader must take the same lock to avoid enumerating the list + // mid-mutation. + internal Tab? FindTempTabSafe(string name, uint world) + { + lock (_tempTabsLock) + return FindTempTab(name, world); + } + internal void DropOldestTempTab() { // Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are @@ -256,25 +266,20 @@ internal sealed class AutoTellTabsService : IDisposable return; } - // Clean up pop-out window if tab is popped out - if (victim.Tab.PopOut) - { - var popout = _plugin.ChatLogWindow.ActivePopouts.FirstOrDefault(p => - p.TabIdentifier == victim.Tab.Identifier - ); - if (popout != null) - { - popout.IsOpen = false; - } - } - + var dropped = victim.Tab; Plugin.Config.Tabs.RemoveAt(victim.Index); - // Re-anchor active tab to avoid silent switch when tab is dropped - if (victim.Index <= _plugin.LastTab) + // Re-anchor the UI selection if it pointed at the dropped tab, and close any + // pop-out window the dropped tab owned. Both run on the PendingMessage worker + // thread and touch window state the Draw path reads (OnTabActivated re-seed + + // the pool's Unbind), so marshal onto the framework thread to serialize with + // Draw (reference_dalamud_framework_thread). TryClose is idempotent: a tab that + // was never popped is a silent no-op. + Plugin.Framework.RunOnFrameworkThread(() => { - _plugin.WantedTab = 0; - } + _plugin.ChannelPopoutPool.TryClose(dropped.Identifier); + _plugin.MainWindow?.ResetActiveTabIfRemoved(dropped); + }); } private void SpawnTempTab((string Name, uint World) partner, Message currentMessage) @@ -286,13 +291,29 @@ internal sealed class AutoTellTabsService : IDisposable tab.AddMessage(currentMessage, unread: true); - // Open as pop-out if configured (set before Tabs.Add for next render-tick) + // Flag the tab as a pop-out if configured; the marshalled TryOpen below reads + // that flag to open the real window. if (Plugin.Config.AutoTellTabsOpenAsPopout) { tab.PopOut = true; } Plugin.Config.Tabs.Add(tab); + + // Actually open the pop-out window for the flagged tab — without this the + // flag was dead (a PopOut tab with no window). SpawnTempTab runs on the + // PendingMessage worker thread under _tempTabsLock; TryOpen does + // OnTabActivated + Bind (window state Draw reads), so marshal onto the + // framework thread. If the pool is full, drop the flag so it never claims a + // window it didn't get (flag/window parity). + if (tab.PopOut) + { + Plugin.Framework.RunOnFrameworkThread(() => + { + if (!_plugin.ChannelPopoutPool.TryOpen(tab)) + tab.PopOut = false; + }); + } } private static Tab BuildTempTab(string playerName, uint worldRowId) @@ -425,37 +446,31 @@ internal sealed class AutoTellTabsService : IDisposable { // Pinned TempTabs must survive char-switch — that's the whole point // of pinning. Only unpinned ones get stripped. - var lastIndex = _plugin.LastTab; - var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; - var currentWasUnpinnedTempTab = - lastIndexValid - && TabLifecycleHelpers.IsInUnpinnedPool(Plugin.Config.Tabs[lastIndex]); + var active = _plugin.MainWindow?.ActiveTab; var poppedTempTabIds = Plugin - .Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut) + .Config.Tabs.Where(t => + TabLifecycleHelpers.IsInUnpinnedPool(t) + && _plugin.ChannelPopoutPool.IsOpen(t.Identifier) + ) .Select(t => t.Identifier) .ToList(); - if (poppedTempTabIds.Count > 0) - { - var poppedSet = poppedTempTabIds.ToHashSet(); - foreach ( - var popout in _plugin - .ChatLogWindow.ActivePopouts.Where(p => poppedSet.Contains(p.TabIdentifier)) - .ToList() - ) - { - popout.IsOpen = false; - } - } + + // Close any pop-out window an unpinned temp tab owns before the tabs leave + // the list. Filtering on the live pool (not the PopOut flag) also catches + // manually right-clicked pop-outs, which never set the flag. + foreach (var id in poppedTempTabIds) + _plugin.ChannelPopoutPool.TryClose(id); Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool); - // Force switch to tab 0 if active tab was an unpinned temp tab or - // index is now out of range. Pinned tabs survive — no switch needed. - var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count; - if (currentWasUnpinnedTempTab || !stillValid) + // Re-anchor the UI selection if the active tab was one of the stripped + // unpinned temp tabs (reference predicate, not an index). Logout is a + // framework-thread event, so this is already serialized with Draw — no + // marshalling needed here, unlike the worker-thread eviction path. + if (active is { } a && TabLifecycleHelpers.IsInUnpinnedPool(a)) { - _plugin.WantedTab = 0; + _plugin.MainWindow?.ResetActiveTabIfRemoved(a); } } } @@ -514,9 +529,12 @@ internal sealed class AutoTellTabsService : IDisposable return; } - tab.IsTempTab = false; - tab.IsPinned = false; - tab.TellTarget = TellTarget.Empty(); + // Drops the temp/pin flags, the persisted tell target AND the runtime + // channel's tell state. The runtime-channel clear is the CORR-1 guard — + // see StripTellBindingOnPromote; clearing Tab.TellTarget alone would leave + // CurrentChannel.Channel == Tell + a stale target and route a typed line + // silently as /tell to the old partner. + TabLifecycleHelpers.StripTellBindingOnPromote(tab); _logger.LogDebug($"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)"); _plugin.SaveConfig(); } diff --git a/HellionChat/Branding/BrandingLinks.cs b/HellionChat/Branding/BrandingLinks.cs index f3f3a08..7b62899 100644 --- a/HellionChat/Branding/BrandingLinks.cs +++ b/HellionChat/Branding/BrandingLinks.cs @@ -10,6 +10,8 @@ internal static class BrandingLinks public const string HellionForgeGitea = "https://gitea.hellion-forge.cloud/Hellion-Forge"; public const string HellionChatRepo = "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat"; + public const string HellionChatCustomRepoManifest = + "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/repo.json"; public const string HellionForgeWebsite = "https://hellion-forge.cloud"; public const string HellionMediaWebsite = "https://hellion-media.de/de"; @@ -26,6 +28,7 @@ internal static class BrandingLinks HellionForgeDiscordInvite, HellionForgeGitea, HellionChatRepo, + HellionChatCustomRepoManifest, HellionForgeWebsite, HellionMediaWebsite ); diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs index 644bf42..3ef8a62 100755 --- a/HellionChat/Configuration.cs +++ b/HellionChat/Configuration.cs @@ -35,7 +35,7 @@ public class ConfigKeyBind [Serializable] public class Configuration : IPluginConfiguration { - private const int LatestVersion = 19; + internal const int LatestVersion = 23; public int Version { get; set; } = LatestVersion; @@ -172,10 +172,16 @@ public class Configuration : IPluginConfiguration public HashSet InactivityHideExtraChatChannels = []; public bool ShowHideButton = true; public bool NativeItemTooltips = true; + public bool ScreenshotMode; public bool PrettierTimestamps = true; public bool MoreCompactPretty; public bool HideSameTimestamps = true; 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; public bool PrintChangelog = true; public bool OnlyPreviewIf; @@ -252,11 +258,40 @@ public class Configuration : IPluginConfiguration 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; + public TellAutoOpenMode TellAutoOpenMode = TellAutoOpenMode.Sidebar; + + // 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; + public void UpdateFrom(Configuration other, bool backToOriginal) { if (backToOriginal) + { + // NOTE (v1.8.0): this only flips the PopOut flag back. If a future + // caller ever wires UpdateFrom(backToOriginal: true) to a live + // settings-cancel path, that CALL-SITE must also iterate + // ChannelPopoutPool.TryClose over the affected Tab.Identifiers, + // otherwise pool windows stay IsOpen=true while the flag is false + // (orphan window). The pool is not reachable from this POCO by design. foreach (var tab in Tabs.Where(t => t.PopOut)) tab.PopOut = false; + } HideChat = other.HideChat; HideDuringCutscenes = other.HideDuringCutscenes; @@ -276,6 +311,7 @@ public class Configuration : IPluginConfiguration InactivityHideExtraChatChannels = other.InactivityHideExtraChatChannels.ToHashSet(); ShowHideButton = other.ShowHideButton; NativeItemTooltips = other.NativeItemTooltips; + ScreenshotMode = other.ScreenshotMode; PrettierTimestamps = other.PrettierTimestamps; MoreCompactPretty = other.MoreCompactPretty; HideSameTimestamps = other.HideSameTimestamps; @@ -392,9 +428,33 @@ public class Configuration : IPluginConfiguration WorldSuffixMode = other.WorldSuffixMode; NameFormMode = other.NameFormMode; + + MainWindowOpen = other.MainWindowOpen; + SettingsWindowOpen = other.SettingsWindowOpen; + MaxParallelPopouts = other.MaxParallelPopouts; + TellAutoOpenMode = other.TellAutoOpenMode; + TellAutoOpenSwitchAlways = other.TellAutoOpenSwitchAlways; + SidebarAutoSwitchThresholdPx = other.SidebarAutoSwitchThresholdPx; + MainWindowLayoutMode = other.MainWindowLayoutMode; } } +[Serializable] +public enum TellAutoOpenMode +{ + Off, + Sidebar, + TopTab, + Popout, +} + +[Serializable] +public enum MainWindowLayoutMode +{ + Sidebar, + TopTabs, +} + [Serializable] public enum UnreadMode { diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs index 35a50d6..8b7fb6d 100644 --- a/HellionChat/FontManager.cs +++ b/HellionChat/FontManager.cs @@ -6,6 +6,7 @@ using Dalamud.Interface.GameFonts; using Dalamud.Interface.ManagedFontAtlas; using Dalamud.Interface.Utility; using Dalamud.Plugin; +using HellionChat.Themes; namespace HellionChat; @@ -39,6 +40,24 @@ public sealed class FontManager : IDisposable internal IFontHandle? RegularFont; internal IFontHandle? ItalicFont; + // Wired post-build (B4b-3); a Func keeps FontManager off the theme layer. + private Func? _typographySource; + + // Lets RebuildDelegateFontsIfChanged skip rebuilds when the size is unchanged. + private (float Global, float Symbols) _lastBuiltFingerprint; + + // True once every required atlas-owned handle reports Available. Components + // gate their first-frame draw on this — without it the layout math would + // run against placeholder font metrics and snap when the real atlas + // finishes building. ItalicFont being null means italics are disabled in + // config, which is a ready state, not a pending one. + public bool FontsReady => + Axis.Available + && AxisItalic.Available + && FontAwesome.Available + && RegularFont is { Available: true } + && (ItalicFont is null || ItalicFont.Available); + private ushort[] Ranges = []; private ushort[] JpRange = []; @@ -92,6 +111,9 @@ public sealed class FontManager : IDisposable if (Plugin.Config.ItalicEnabled) ItalicFont = BuildItalicFontHandle(atlas); } + + // Source is still null here, so this is the config-only baseline. + _lastBuiltFingerprint = EffectiveFontFingerprint(); } // Called from the settings save path when one of the font-related @@ -113,6 +135,37 @@ public sealed class FontManager : IDisposable ItalicFont?.Dispose(); ItalicFont = Plugin.Config.ItalicEnabled ? BuildItalicFontHandle(atlas) : null; + + _lastBuiltFingerprint = EffectiveFontFingerprint(); + } + + public void SetTypographySource(Func source) => _typographySource = source; + + internal float ResolveGlobalFontPt() => + FontSizeResolver.ResolveGlobalPt( + _typographySource?.Invoke(), + Plugin.Config.UseHellionFont, + Plugin.Config.FontSizeV2, + Plugin.Config.GlobalFontV2.SizePt + ); + + internal float ResolveSymbolsFontPt() => + FontSizeResolver.ResolveSymbolsPt( + _typographySource?.Invoke(), + Plugin.Config.SymbolsFontSizeV2 + ); + + internal (float Global, float Symbols) EffectiveFontFingerprint() => + (ResolveGlobalFontPt(), ResolveSymbolsFontPt()); + + // Rebuilds only when the effective size changed (live fingerprint, TOCTOU-free). + // The atlas rebuild must run on the framework/draw thread — callers ensure that. + internal void RebuildDelegateFontsIfChanged() + { + if (EffectiveFontFingerprint() != _lastBuiltFingerprint) + { + RebuildDelegateFonts(); + } } // Instance method so Ranges / JpRange are reachable without parameter @@ -121,12 +174,7 @@ public sealed class FontManager : IDisposable atlas.NewDelegateFontHandle(e => e.OnPreBuild(tk => { - // UseHellionFont swaps the source font but keeps the size - // selector tied to FontSizeV2 (the bundled font ships as - // a single weight). - var basePt = Plugin.Config.UseHellionFont - ? Plugin.Config.FontSizeV2 - : Plugin.Config.GlobalFontV2.SizePt; + var basePt = ResolveGlobalFontPt(); var config = new SafeFontConfig { SizePt = basePt, GlyphRanges = Ranges }; // Missing embedded resource falls back to the configured // system font instead of taking the whole UiBuilder down. @@ -152,7 +200,7 @@ public sealed class FontManager : IDisposable "noto-cjk-fallback" ); - config.SizePt = Plugin.Config.SymbolsFontSizeV2; + config.SizePt = ResolveSymbolsFontPt(); tk.AddGameSymbol(config); tk.Font = config.MergeFont; @@ -189,7 +237,7 @@ public sealed class FontManager : IDisposable "noto-cjk-fallback" ); - config.SizePt = Plugin.Config.SymbolsFontSizeV2; + config.SizePt = ResolveSymbolsFontPt(); tk.AddGameSymbol(config); tk.Font = config.MergeFont; diff --git a/HellionChat/FontSizeResolver.cs b/HellionChat/FontSizeResolver.cs new file mode 100644 index 0000000..60d1dbd --- /dev/null +++ b/HellionChat/FontSizeResolver.cs @@ -0,0 +1,18 @@ +using HellionChat.Themes; + +namespace HellionChat; + +// Pure size resolution, split out of FontManager so it is unit-testable without +// building the font atlas. A typography override wins; null falls back to config. +internal static class FontSizeResolver +{ + internal static float ResolveGlobalPt( + ThemeTypography? typography, + bool useHellionFont, + float fontSizeV2, + float globalSizePt + ) => typography?.OverrideGlobalFontSizePt ?? (useHellionFont ? fontSizeV2 : globalSizePt); + + internal static float ResolveSymbolsPt(ThemeTypography? typography, float symbolsSizePt) => + typography?.OverrideSymbolsFontSizePt ?? symbolsSizePt; +} diff --git a/HellionChat/GameFunctions/Chat.cs b/HellionChat/GameFunctions/Chat.cs index 0523bda..a4e77f1 100755 --- a/HellionChat/GameFunctions/Chat.cs +++ b/HellionChat/GameFunctions/Chat.cs @@ -232,15 +232,13 @@ internal sealed unsafe class Chat : IDisposable if (c != '\0' && !char.IsControl(c)) input = c.ToString(); - try + // Seed the just-typed character into our input field and focus it, the + // same InputBar.AppendPending + Activate prefill path inventory item-links + // use. Prefill-only — no tab switch (Flo decision 2026-06-15). + if (input != null) { - Plugin.ChatLogWindow.Activated( - new ChatActivatedArgs(new ChannelSwitchInfo(null)) { Input = input } - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in chat Activated event"); + Plugin.InputBar.AppendPending(input); + Plugin.InputBar.Activate = true; } }); } @@ -255,22 +253,12 @@ internal sealed unsafe class Chat : IDisposable addIfNotPresent = add; } - try + // Route the addIfNotPresent token into the InputBar so inventory + // right-click "Link item" reaches our input field instead of being lost. + if (addIfNotPresent != null && !Plugin.InputBar.PendingMessage.Contains(addIfNotPresent)) { - // Prevent duplicate calls - if (Plugin.ChatLogWindow.TellSpecial) - return ChatLogRefreshHook!.Original(log, eventId, value); - - Plugin.ChatLogWindow.Activated( - new ChatActivatedArgs(new ChannelSwitchInfo(null)) - { - AddIfNotPresent = addIfNotPresent, - } - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in chat Activated event"); + Plugin.InputBar.AppendPending(addIfNotPresent); + Plugin.InputBar.Activate = true; } return 1; // Prevent vanilla chat log from gaining focus @@ -342,28 +330,18 @@ internal sealed unsafe class Chat : IDisposable { if (playerName != null) { - try - { - var target = new TellTarget( - playerName->ToString(), - worldId, - contentId, - (TellReason)reason - ); - Plugin.ChatLogWindow.Activated( - new ChatActivatedArgs( - new ChannelSwitchInfo(InputChannel.Tell, permanent: setChatType) - ) - { - TellReason = (TellReason)reason, - TellTarget = target, - } - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in chat Activated event"); - } + // Right-click -> Send Tell: prefill our input the same way our own + // "Send Tell" payload menu does (PayloadHandler), then focus. Prefill- + // only — no tab switch, no ChatActivatedArgs revival (Flo decision + // 2026-06-15). The game supplies worldName here, so no sheet lookup. + var tellName = playerName->ToString(); + var tellWorld = worldName != null ? worldName->ToString() : string.Empty; + var tellCommand = $"/tell {tellName}"; + if (!string.IsNullOrEmpty(tellWorld)) + tellCommand += $"@{tellWorld}"; + tellCommand += " "; + Plugin.InputBar.SetPendingMessage(tellCommand); + Plugin.InputBar.Activate = true; } return SetChatLogTellTargetHook!.Original( @@ -393,27 +371,17 @@ internal sealed unsafe class Chat : IDisposable if (playerName != null) { - try - { - var target = new TellTarget( - playerName->ToString(), - worldId, - contentId, - (TellReason)reason - ); - Plugin.ChatLogWindow.Activated( - new ChatActivatedArgs(new ChannelSwitchInfo(InputChannel.Tell)) - { - TellReason = (TellReason)reason, - TellTarget = target, - TellSpecial = Sheets.IsInForay(), // Handle Eureka/Bozja special - } - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in chat Activated event"); - } + // In-foray right-click -> Send Tell: same prefill path as the non-foray + // tell. The foray-specific TellSpecial channel routing stays deferred + // (v1.8.1, SetEurekaTellChannel) — prefill-only here (Flo decision 2026-06-15). + var forayName = playerName->ToString(); + var forayWorld = worldName != null ? worldName->ToString() : string.Empty; + var forayCommand = $"/tell {forayName}"; + if (!string.IsNullOrEmpty(forayWorld)) + forayCommand += $"@{forayWorld}"; + forayCommand += " "; + Plugin.InputBar.SetPendingMessage(forayCommand); + Plugin.InputBar.Activate = true; } ContextMenuTellInForayHook!.Original( @@ -570,9 +538,8 @@ internal sealed unsafe class Chat : IDisposable if (!Plugin.CurrentTab.CurrentChannel.UseTempChannel) Plugin.CurrentTab.CurrentChannel.UseTempChannel = true; - // Send tell via CommandInner later and let the game handle it - // Only works because we use the SetTellTargetInForay function to set all required information - Plugin.ChatLogWindow.TellSpecial = true; + // Send tell via CommandInner later and let the game handle it. + // TellSpecial gate is offline until the new chat layer reads it. var utfName = Utf8String.FromString(name); var utfWorld = Utf8String.FromString(worldName); diff --git a/HellionChat/GameFunctions/KeybindManager.cs b/HellionChat/GameFunctions/KeybindManager.cs index 64aa401..1b8f0a0 100644 --- a/HellionChat/GameFunctions/KeybindManager.cs +++ b/HellionChat/GameFunctions/KeybindManager.cs @@ -504,33 +504,44 @@ internal unsafe class KeybindManager : IDisposable if (!KeybindsToIntercept.TryGetValue(currentBest.Item2, out var info)) return; - try - { - TellReason? reason = info.Channel == InputChannel.Tell ? TellReason.Reply : null; - Plugin.ChatLogWindow.Activated(new ChatActivatedArgs(info) { TellReason = reason }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in chat Activated event"); - } - } + // Re-surface the chat-activation entry point retired in v1.6.0: a chat-open + // keybind shows + focuses the window, restoring it from a user-hide or a + // closed state. + Plugin.Instance.MainWindow?.ActivateChat(); - // v0.6.0 — central dispatch for ChatTabForward/Backward. If a pop-out - // window currently has its compact input focused, the keybind is - // forwarded into that pop-out's ChatInputBar so the user navigates - // tabs in the window they are typing in. Otherwise the main window - // handles it (= v0.5.x behavior). - private void DispatchTabDelta(int delta) - { - foreach (var popout in Plugin.ChatLogWindow.ActivePopouts) + // Direct channel-switch binds (CMD_SAY/PARTY/numbered linkshells/…): switch the + // game channel AND mirror it onto the active tab so the input pill shows the + // real send target (pill-sync, Flo decision 2026-06-15). Rotation binds (REPLY / + // linkshell-cycle, Rotate != None) are skipped; the temp-vs-permanent distinction + // (v1.5.6's UseTempChannel / info.Permanent) collapses to one permanent-style + // switch here — restoring it is the keybind-routing follow-cycle. + if (info.Channel is { } channel && info.Rotate == RotateMode.None) { - if (popout.HasFocusedInputBar && popout.InputBar != null) + Plugin.Instance.Functions.Chat.SetChannel(channel); + // Only mirror onto the tab when the game actually accepted the switch — an + // empty linkshell slot leaves the game channel untouched, so the pill must + // stay put rather than show a target the game will not send to. + if ( + Chat.IsChannelOrExistingLinkshell(channel) + && Plugin.Instance.MainWindow?.ActiveTab is { } activeTab + ) { - popout.InputBar.HandleKeybindForward(delta); - return; + activeTab.CurrentChannel.SetChannel(channel); + activeTab.CurrentChannel.TellTarget = null; + activeTab.CurrentChannel.ResetTempChannel(); } } - Plugin.ChatLogWindow.ChangeTabDelta(delta); + + // Prefill text binds (CMD_COMMAND seeds "/"): drop the token into our input. + if (info.Text is { } text) + Plugin.Instance.InputBar.SetPendingMessage(text); + } + + // Pop-out input-bar focus-forward stays deferred (no focus contract yet) — + // main-window tabs only. + private void DispatchTabDelta(int delta) + { + Plugin.Instance.MainWindow?.ChangeTabDelta(delta); } private static Keybind GetKeybind(string id) diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj index 208b462..abf725d 100644 --- a/HellionChat/HellionChat.csproj +++ b/HellionChat/HellionChat.csproj @@ -1,7 +1,7 @@ - 1.5.6 + 1.8.8 enable enable diff --git a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs index 9ffb54f..65d36a8 100644 --- a/HellionChat/Infrastructure/Hosting/InitHostedServices.cs +++ b/HellionChat/Infrastructure/Hosting/InitHostedServices.cs @@ -1,7 +1,11 @@ +using Dalamud.Game.Addon.Lifecycle; using Dalamud.Plugin; using HellionChat.Integrations; using HellionChat.Ipc; using HellionChat.Themes; +using HellionChat.Ui; +using HellionChat.Ui.Components; +using HellionChat.Ui.Windows; using Microsoft.Extensions.Hosting; namespace HellionChat.Infrastructure.Hosting; @@ -12,16 +16,26 @@ namespace HellionChat.Infrastructure.Hosting; // at Build, which runs the service ctor (IPC subscribe etc.) right then // instead of lazily on first GetRequiredService. -internal sealed class ThemeRegistryInitHostedService(ThemeRegistry registry) : IHostedService +internal sealed class ThemeRegistryInitHostedService( + ThemeRegistry registry, + FontManager fontManager +) : IHostedService { - public Task StartAsync(CancellationToken cancellationToken) + public async Task StartAsync(CancellationToken cancellationToken) { // Materialise the lazy AllCustom enumerable so the slug lookup hits a // warm cache; otherwise the first Switch falls through to the built-in // default when Config.Theme points at a custom slug. foreach (var _ in registry.AllCustom()) { } registry.SwitchSilent(Plugin.Config.Theme); - return Task.CompletedTask; + + // B4b-3: point font sizes at the active theme's typography, wire future + // theme switches to the atlas rebuild, and apply the boot theme's override. + fontManager.SetTypographySource(() => registry.Active.Typography); + registry.SetActiveChangedCallback(() => fontManager.RebuildDelegateFontsIfChanged()); + await Plugin.Framework.RunOnFrameworkThread(() => + fontManager.RebuildDelegateFontsIfChanged() + ); } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; @@ -87,6 +101,18 @@ internal sealed class AutoTellTabsServiceInitHostedService(AutoTellTabsService s public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } +internal sealed class TellRouterServiceInitHostedService(Services.TellRouterService service) + : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + service.Initialize(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + // Eager-resolve trigger: resolving FailedTellNotifier in this adapter's ctor // enables its game hook during host startup. StartAsync itself is a no-op. internal sealed class FailedTellNotifierInitHostedService(FailedTellNotifier notifier) @@ -101,3 +127,82 @@ internal sealed class FailedTellNotifierInitHostedService(FailedTellNotifier not public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } + +internal sealed class PayloadHandlerInitHostedService( + PayloadHandler payloadHandler, + MessageList messageList +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + // §6.2 cycle-resolution: both singletons exist by the time HostedServices + // run, so this is the first safe point to wire the setter. + messageList.AttachPayloadHandler(payloadHandler); + + // IAddonLifecycle thread-affinity is not explicitly documented; wrap is + // defensive insurance — mirrors the window-registration RunOnFrameworkThread + // pattern established in PluginLifecycle.cs. + await Plugin.Framework.RunOnFrameworkThread(() => + { + Plugin.AddonLifecycle.RegisterListener( + AddonEvent.PostUpdate, + "ItemDetail", + payloadHandler.MoveTooltip + ); + Plugin.AddonLifecycle.RegisterListener( + AddonEvent.PostUpdate, + "ActionDetail", + payloadHandler.MoveTooltip + ); + }); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + await Plugin.Framework.RunOnFrameworkThread(() => + { + // Single call using the params-overload removes the delegate from all addons it was registered for (ItemDetail + ActionDetail both cleaned in one shot). + Plugin.AddonLifecycle.UnregisterListener(payloadHandler.MoveTooltip); + }); + } +} + +// Wires MainWindow into CommandHelpWindow post-container-build. CommandHelpWindow +// cannot take MainWindow as a ctor-param because that would close the cycle +// InputBar -> CommandHelpWindow -> MainWindow -> InputBar (MS.DI does not catch +// it through FactoryCallSite registrations and the resolve recurses silently). +// Both singletons exist by host.StartAsync time, so this is the first safe point +// to wire the setter — same §6.2 pattern as MessageList.AttachPayloadHandler. +internal sealed class CommandHelpWindowInitHostedService( + CommandHelpWindow commandHelpWindow, + MainWindow mainWindow +) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + commandHelpWindow.AttachMainWindow(mainWindow); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +// Attaches the singleton PayloadHandler to every pre-allocated pop-out +// window's MessageList post-container-build. Pool/window cannot take the +// PayloadHandler via ctor (that would close the silent FactoryCallSite cycle — +// same §6.2 reason as MessageList.AttachPayloadHandler / CommandHelpWindow. +// AttachMainWindow). Both singletons exist by host.StartAsync time. +internal sealed class ChannelPopoutInitHostedService( + ChannelPopoutPool pool, + PayloadHandler payloadHandler +) : IHostedService +{ + public Task StartAsync(CancellationToken cancellationToken) + { + foreach (var window in pool.Instances) + window.AttachPayloadHandler(payloadHandler); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/HellionChat/Integrations/HonorificService.cs b/HellionChat/Integrations/HonorificService.cs index 6a37588..5f139c6 100644 --- a/HellionChat/Integrations/HonorificService.cs +++ b/HellionChat/Integrations/HonorificService.cs @@ -195,4 +195,23 @@ internal sealed class HonorificService : IDisposable return false; return true; } + + // Test seam: the three status fields are private-set and IPC-driven, which a + // headless /xlperf run can't reach (Honorific is usually absent in tests). + // Callers MUST snapshot the prior values and restore them in CleanUp, and + // MUST drive Set -> Draw -> Assert within ONE synchronous RunStep (never + // Waiting between Set and Assert) — a between-frame OnReady/OnTitleChanged + // would otherwise clobber this state and a CleanUp restore can't un-corrupt a + // mid-flight assertion. (A FontsReady precondition gate returning Waiting + // BEFORE the snapshot/Set is fine — nothing is mutated yet.) + internal void TestOnly_SetState( + bool isAvailable, + (uint Major, uint Minor)? detectedApiVersion, + HonorificTitleData? title + ) + { + IsAvailable = isAvailable; + DetectedApiVersion = detectedApiVersion; + CurrentTitle = title; + } } diff --git a/HellionChat/Integrations/HonorificStatus.cs b/HellionChat/Integrations/HonorificStatus.cs new file mode 100644 index 0000000..b50d250 --- /dev/null +++ b/HellionChat/Integrations/HonorificStatus.cs @@ -0,0 +1,29 @@ +namespace HellionChat.Integrations; + +internal enum HonorificStatusKind +{ + NotInstalled, + Incompatible, + Detected, +} + +internal static class HonorificStatus +{ + // Mirrors the 1.5.6 three-state discriminator (1d3b429:About.cs:171/183/196): + // it keys on IsAvailable + the *nullability* of DetectedApiVersion, never a + // recomputed major check. IsAvailable already encodes the compatibility + // result HonorificService set during the initial pull. Null-safe: an + // (isAvailable=true, detectedApiVersion=null) state a test seam can produce + // resolves to NotInstalled rather than dereferencing null. + internal static HonorificStatusKind Resolve( + bool isAvailable, + (uint Major, uint Minor)? detectedApiVersion + ) + { + if (isAvailable && detectedApiVersion is not null) + return HonorificStatusKind.Detected; + if (detectedApiVersion is not null) + return HonorificStatusKind.Incompatible; + return HonorificStatusKind.NotInstalled; + } +} diff --git a/HellionChat/Integrations/HonorificTitleData.cs b/HellionChat/Integrations/HonorificTitleData.cs index 267b7af..ccb2c9a 100644 --- a/HellionChat/Integrations/HonorificTitleData.cs +++ b/HellionChat/Integrations/HonorificTitleData.cs @@ -5,11 +5,10 @@ namespace HellionChat.Integrations; // Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll // so HellionChat loads cleanly when Honorific is absent. // -// Only Glow is rendered. Color3, GradientColourSet and GradientAnimationStyle -// are parsed but unused — the animated gradient lives entirely inside Honorific -// and is not exposed over IPC, so reproducing it here would mean shipping our -// own copy of Honorific's colour palette. The fields stay in the DTO so the -// JSON roundtrip remains lossless. +// Color is rendered in the header title slot (HonorificHeader). Glow, Color3, +// GradientColourSet and GradientAnimationStyle are parsed but not rendered — +// the animated gradient lives inside Honorific and is not exposed over IPC. +// The fields stay in the DTO so the JSON roundtrip remains lossless. internal sealed record HonorificTitleData( string? Title, bool IsPrefix, diff --git a/HellionChat/Ipc/TypingIpc.cs b/HellionChat/Ipc/TypingIpc.cs index 394cc97..57d36b2 100644 --- a/HellionChat/Ipc/TypingIpc.cs +++ b/HellionChat/Ipc/TypingIpc.cs @@ -34,11 +34,13 @@ internal sealed class TypingIpc : IDisposable private ChatInputState LastState; private bool HasState; + private readonly Ui.Components.InputBar _inputBar; private readonly ILogger _logger; - internal TypingIpc(Plugin plugin, ILogger logger) + internal TypingIpc(Plugin plugin, Ui.Components.InputBar inputBar, ILogger logger) { Plugin = plugin; + _inputBar = inputBar; _logger = logger; StateQueryGate = Plugin.Interface.GetIpcProvider( @@ -62,25 +64,34 @@ internal sealed class TypingIpc : IDisposable private ChatInputState BuildState() { - var log = Plugin.ChatLogWindow; - var usedChannel = Plugin.CurrentTab.CurrentChannel; var inputChannel = usedChannel.UseTempChannel ? usedChannel.TempChannel : usedChannel.Channel; var channelType = inputChannel.ToChatType(); + // MainWindow is Phase-1-resolved and never reassigned; + // the `?.` is defense-in-depth for pre-Phase-1 IPC-pulls. + var mainWindowOpen = Plugin.MainWindow?.IsOpen ?? false; + + // Stale-state guard: InputBar's focus and pending-buffer fields are + // only written by DrawInputField. Closing MainWindow freezes them, so + // gate all four state fields on mainWindowOpen. + var inputFocused = mainWindowOpen && _inputBar.IsFocused; + var hasText = mainWindowOpen && _inputBar.PendingLength > 0; + var textLength = mainWindowOpen ? _inputBar.PendingLength : 0; + return ( - InputVisible: !log.IsHidden, - log.InputFocused, - HasText: log.Chat.Length > 0, - IsTyping: log is { InputFocused: true, Chat.Length: > 0 }, - TextLength: log.Chat.Length, + InputVisible: mainWindowOpen, + InputFocused: inputFocused, + HasText: hasText, + IsTyping: hasText, + TextLength: textLength, ChannelType: channelType ); } - private ChatInputState GetState() => BuildState(); + internal ChatInputState GetState() => BuildState(); internal void Update() { diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs index 965d556..2acf577 100644 --- a/HellionChat/MessageManager.cs +++ b/HellionChat/MessageManager.cs @@ -331,36 +331,27 @@ internal class MessageManager : IAsyncDisposable if (Plugin.Config.DatabaseBattleMessages || !message.Code.IsBattle()) Store.UpsertMessage(message); - var currentMatches = Plugin.CurrentTab.Matches(message); - uint? notificationSound = null; + // Snapshot the active tab and whether it shows this message ONCE, so the + // whole loop sees a consistent value (the getter is a cross-thread read of + // MainWindow.ActiveTab). + var currentTab = Plugin.CurrentTab; + var currentTabMatches = currentTab.Matches(message); foreach (var tab in Plugin.Config.Tabs) { - var unread = !( - tab.UnreadMode == UnreadMode.Unseen && Plugin.CurrentTab != tab && currentMatches - ); - if (tab.Matches(message)) - { - tab.AddMessage(message, unread); - - // Per-tab notification sound. Fire once for the first inactive - // tab that wants it, keeping a message matching several - // background tabs from stacking sounds. - // TEST-MIRROR: ../_Helpers/TabSoundDecision.cs - if ( - notificationSound is null - && TabSoundDecision.ShouldPlay( - Plugin.CurrentTab == tab, - tab.EnableNotificationSound, - Plugin.Config.PlaySounds - ) - ) - { - notificationSound = tab.NotificationSoundId; - } - } + tab.AddMessage(message, ShouldCountUnread(tab, currentTab, currentTabMatches)); } + // Deliberate O(2n): the sound pick re-walks the tab list so the selection + // stays pure and SelfTest-able; AddMessage above and playback below keep + // the side effects. + var notificationSound = SelectNotificationSound( + Plugin.Config.Tabs, + Plugin.CurrentTab, + message, + Plugin.Config.PlaySounds + ); + if (notificationSound is { } soundId) { if (soundId is >= 1 and <= 16) @@ -388,6 +379,61 @@ internal class MessageManager : IAsyncDisposable MessageProcessed?.Invoke(message); } + // Pure: picks the sound id for the first inactive tab that wants one, or null. + // No AddMessage, no store write — those stay in the ProcessMessage loop so this + // is exercisable from the SelfTest without polluting tab state. The "first + // match wins" semantics live here via the running 'picked is null' guard, + // keeping a message matching several background tabs from stacking sounds. + // TEST-MIRROR: ../_Helpers/TabSoundDecision.cs + // Unseen ("count only what you haven't seen") suppresses unread on an inactive + // tab when the active tab ALSO shows this message — you already saw it in the + // tab you're looking at (1.5.6 / upstream ChatTwo behavior). Pre-F2 the "active + // tab" was wrongly pinned to Tabs[0], so this fired against the wrong tab; F2 + // recoupled CurrentTab to the REAL active tab, so currentTabMatches is now + // measured against the tab you actually see. All -> always counts; None -> + // counts here and is gated out at the display layer. Pure + SelfTest-able. + internal static bool ShouldCountUnread(Tab tab, Tab currentTab, bool currentTabMatches) => + !( + tab.UnreadMode == UnreadMode.Unseen + && !ReferenceEquals(currentTab, tab) + && currentTabMatches + ); + + internal static uint? SelectNotificationSound( + IEnumerable tabs, + Tab currentTab, + Message probe, + bool playSounds + ) + { + uint? picked = null; + foreach (var tab in tabs) + { + if (!tab.Matches(probe)) + continue; + if ( + picked is null + && TabSoundDecision.ShouldPlay( + currentTab == tab, + tab.EnableNotificationSound, + playSounds + ) + ) + { + picked = tab.NotificationSoundId; + } + } + return picked; + } + + // SelfTest hook — same name discipline as InputBar.TestBuildOutgoingForSelfTest. + internal static uint? TestSelectNotificationSoundForSelfTest( + IEnumerable tabs, + Tab currentTab, + Message probe, + bool playSounds + ) => SelectNotificationSound(tabs, currentTab, probe, playSounds); + internal class NameFormatting { internal string Before { get; private set; } = string.Empty; diff --git a/HellionChat/PayloadHandler.cs b/HellionChat/PayloadHandler.cs old mode 100755 new mode 100644 index 6cda470..46be55b --- a/HellionChat/PayloadHandler.cs +++ b/HellionChat/PayloadHandler.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Game.Addon.Lifecycle; @@ -17,7 +18,9 @@ using FFXIVClientStructs.FFXIV.Client.UI; using FFXIVClientStructs.FFXIV.Component.GUI; using HellionChat.Code; using HellionChat.Resources; -using HellionChat.Ui; +using HellionChat.Themes; +using HellionChat.Ui.Components; +using HellionChat.Ui.Windows; using HellionChat.Util; using Lumina.Excel.Sheets; using Microsoft.Extensions.Logging; @@ -27,25 +30,42 @@ using DalamudPartyFinderPayload = Dalamud.Game.Text.SeStringHandling.Payloads.Pa namespace HellionChat; -public sealed class PayloadHandler +internal sealed class PayloadHandler { private const string PopupId = "hellionchat-context-popup"; + private const uint PopupSfx = 1; - private ChatLogWindow LogWindow { get; } - private (Chunk, Payload?)? Popup { get; set; } + private readonly ThemeRegistry _themes; + private readonly IpcManager _ipc; + private readonly GameFunctions.GameFunctions _functions; + private readonly InputBar _inputBar; + private readonly MainWindow _mainWindow; + private readonly ChunkRenderer _chunkRenderer; + private readonly ILogger _logger; public bool HandleTooltips; public uint HoveredItem; public uint HoverCounter; public uint LastHoverCounter; - private const uint PopupSfx = 1; + private (Chunk, Payload?)? _popup; - private readonly ILogger _logger; - - internal PayloadHandler(ChatLogWindow logWindow, ILogger logger) + public PayloadHandler( + ThemeRegistry themes, + IpcManager ipc, + GameFunctions.GameFunctions functions, + InputBar inputBar, + MainWindow mainWindow, + ChunkRenderer chunkRenderer, + ILogger logger + ) { - LogWindow = logWindow; + _themes = themes; + _ipc = ipc; + _functions = functions; + _inputBar = inputBar; + _mainWindow = mainWindow; + _chunkRenderer = chunkRenderer; _logger = logger; } @@ -64,15 +84,15 @@ public sealed class PayloadHandler private void DrawPopups() { - if (Popup == null) + if (_popup == null) return; - var (chunk, payload) = Popup.Value; + var (chunk, payload) = _popup.Value; using var popup = ImRaii.Popup(PopupId); if (!popup.Success) { - Popup = null; + _popup = null; return; } @@ -104,7 +124,7 @@ public sealed class PayloadHandler private void Integrations(Chunk chunk, Payload? payload) { - var registered = LogWindow.Plugin.Ipc.Registered; + var registered = _ipc.Registered; if (registered.Count == 0) return; @@ -120,12 +140,12 @@ public sealed class PayloadHandler return; var cursor = ImGui.GetCursorPos(); - foreach (var id in registered) + foreach (var integrationId in registered) { try { - LogWindow.Plugin.Ipc.Invoke( - id, + _ipc.Invoke( + integrationId, sender, contentId, payload, @@ -166,10 +186,10 @@ public sealed class PayloadHandler return; } - ImGui.Checkbox(Language.Context_ScreenshotMode, ref LogWindow.ScreenshotMode); + ImGui.Checkbox(Language.Context_ScreenshotMode, ref Plugin.Config.ScreenshotMode); if (ImGui.Selectable(Language.Context_HideChat)) - LogWindow.UserHide(); + Plugin.Config.HideChat = true; if (chunk.Message is { } message) { @@ -210,73 +230,354 @@ public sealed class PayloadHandler .Where(chunk => chunk is TextChunk) .Cast() .Select(text => text.Content) - .Aggregate(string.Concat); + .Aggregate(string.Empty, string.Concat); } - internal unsafe void Click(Chunk chunk, Payload? payload, ImGuiMouseButton button) + private void DrawPlayerPopup(Chunk chunk, PlayerPayload player) { - if (Plugin.Config.PlaySounds) - UIGlobals.PlaySoundEffect(PopupSfx); + // Possible that GMs return a null payload + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (player == null) + return; - switch (button) + var world = player.World; + if (chunk.Message?.Code.Type == ChatType.FreeCompanyLoginLogout) + if (Plugin.PlayerState.HomeWorld.IsValid) + world = Plugin.PlayerState.HomeWorld; + + var name = new List { new TextChunk(ChunkSource.None, null, player.PlayerName) }; + if (world.Value.IsPublic) { - case ImGuiMouseButton.Left: - LeftClickPayload(chunk, payload); - break; - case ImGuiMouseButton.Right: - RightClickPayload(chunk, payload); - break; + name.AddRange([ + new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld), + new TextChunk(ChunkSource.None, null, world.Value.Name.ExtractText()), + ]); } - } - internal void Hover(Payload payload) - { - var hoverSize = 350f * ImGuiHelpers.GlobalScale; + _chunkRenderer.DrawChunks(name, false); + ImGui.Separator(); - switch (payload) + var validContentId = chunk.Message?.ContentId is not (null or 0); + if (ImGui.Selectable(Language.Context_SendTell)) { - case StatusPayload status: - DoHover(() => HoverStatus(status), hoverSize); - break; - case ItemPayload item: - if (Plugin.Config.NativeItemTooltips) + // Eureka, Bozja and Occult need special handling as tells work different + if (!Sheets.IsInForay()) + { + // §6.9: single SetPendingMessage call; v1.5.6 used incremental Chat += writes + var builder = $"/tell {player.PlayerName}"; + if (world.Value.IsPublic) + builder += $"@{world.Value.Name}"; + + builder += " "; + _inputBar.SetPendingMessage(builder); + } + else if (validContentId) + { + _functions.Chat.SetEurekaTellChannel( + player.PlayerName, + world.Value.Name.ToString(), + (ushort)world.RowId, + 0, + chunk.Message!.ContentId, + 0, + false + ); + } + + _inputBar.Activate = true; + } + + if (world.Value.IsPublic) + { + var party = Plugin.PartyList; + var leader = party[(int)party.PartyLeaderIndex]?.ContentId; + var isLeader = party.Length == 0 || Plugin.PlayerState.ContentId == leader; + var member = party.FirstOrDefault(member => + member.Name.TextValue == player.PlayerName && member.World.RowId == world.RowId + ); + var isInParty = member != null; + var inInstance = GameFunctions.GameFunctions.IsInInstance(); + var inPartyInstance = + Sheets + .TerritorySheet.GetRow(Plugin.ClientState.TerritoryType) + .TerritoryIntendedUse.RowId + is (41 or 47 or 48 or 52 or 53 or 61); + if (isLeader) + { + if (!isInParty) { - if (!HandleTooltips || HoveredItem != item.RawItemId) + if (inInstance && inPartyInstance) { - HandleTooltips = true; - HoveredItem = item.RawItemId; - HoverCounter = LastHoverCounter = 0; - - GameFunctions.GameFunctions.OpenItemTooltip(item.RawItemId, item.Kind); + if (validContentId && ImGui.Selectable(Language.Context_InviteToParty)) + GameFunctions.Party.InviteInInstance(chunk.Message!.ContentId); } - else + else if (!inInstance) { - LastHoverCounter = HoverCounter; - } + using var menu = ImRaii.Menu(Language.Context_InviteToParty); + if (menu.Success) + { + if (ImGui.Selectable(Language.Context_InviteToParty_SameWorld)) + GameFunctions.Party.InviteSameWorld( + player.PlayerName, + (ushort)world.RowId, + chunk.Message?.ContentId ?? 0 + ); - return; + if ( + validContentId + && ImGui.Selectable(Language.Context_InviteToParty_DifferentWorld) + ) + GameFunctions.Party.InviteOtherWorld( + chunk.Message!.ContentId, + (ushort)world.RowId + ); + } + } } - DoHover(() => HoverItem(item), hoverSize); + if (isInParty && member != null && (!inInstance || (inInstance && inPartyInstance))) + { + if (ImGui.Selectable(Language.Context_Promote)) + GameFunctions.Party.Promote(player.PlayerName, member.ContentId); + + if (ImGui.Selectable(Language.Context_KickFromParty)) + GameFunctions.Party.Kick(player.PlayerName, member.ContentId); + } + } + + var isFriend = GameFunctions + .GameFunctions.GetFriends() + .Any(friend => + friend.NameString == player.PlayerName && friend.HomeWorld == world.RowId + ); + if (!isFriend && ImGui.Selectable(Language.Context_SendFriendRequest)) + _functions.SendFriendRequest(player.PlayerName, (ushort)world.RowId); + + using (var menuBlockFunctions = ImRaii.Menu(Language.Context_BlockFunctions)) + { + if (menuBlockFunctions.Success) + { + if (ImGui.Selectable(Language.Context_AddToBlacklist)) + _functions.AddToBlacklist(player.PlayerName, (ushort)world.RowId); + + if (chunk.Message != null) + { + var message = chunk.Message; + + if ( + message.AccountId != 0 + && ImGui.Selectable(Language.Context_AddToMuteList) + ) + _functions.AddToMuteList( + message.AccountId, + message.ContentId, + player.PlayerName, + (short)world.RowId + ); + + if (ImGui.Selectable(Language.Context_AddToTermsFilter)) + _functions.AddToTermsList(message.ContentSource); + } + } + } + + if ( + GameFunctions.GameFunctions.IsMentor() + && ImGui.Selectable(Language.Context_InviteToNoviceNetwork) + ) + GameFunctions.Context.InviteToNoviceNetwork(player.PlayerName, (ushort)world.RowId); + } + + var inputChannel = chunk.Message?.Code.Type.ToInputChannel(); + if (inputChannel != null && ImGui.Selectable(Language.Context_ReplyInSelectedChatMode)) + { + // §6.3: route channel-switch through MainWindow's active tab + _mainWindow.ActiveTab?.CurrentChannel?.SetChannel(inputChannel.Value); + _inputBar.Activate = true; + } + + if (ImGui.Selectable(Language.Context_Target) && FindCharacterForPayload(player) is { } obj) + Plugin.TargetManager.Target = obj; + + if (validContentId && ImGui.Selectable(Language.Context_AdventurerPlate)) + if (!GameFunctions.GameFunctions.TryOpenAdventurerPlate(chunk.Message!.ContentId)) + WrapperUtil.AddNotification( + Language.Context_AdventurerPlateError, + NotificationType.Warning + ); + } + + // Returns the first matching IPlayerCharacter in ObjectTable, null if out of render range. + private IPlayerCharacter? FindCharacterForPayload(PlayerPayload payload) + { + foreach (var obj in Plugin.ObjectTable) + { + if (obj is not IPlayerCharacter character) + continue; + + if (character.Name.TextValue != payload.PlayerName) + continue; + + if (payload.World.Value.IsPublic && character.HomeWorld.RowId != payload.World.RowId) + continue; + + return character; + } + + return null; + } + + private void DrawItemPopup(ItemPayload payload) + { + if (payload.Kind == ItemKind.EventItem) + { + DrawEventItemPopup(payload); + return; + } + + if (!Sheets.ItemSheet.TryGetRow(payload.ItemId, out var itemRow)) + return; + + var hq = payload.Kind == ItemKind.Hq; + if ( + Plugin + .TextureProvider.GetFromGameIcon(new GameIconLookup(itemRow.Icon, hq)) + .GetWrapOrDefault() is + { } icon + ) + InlineIcon(icon); + + var name = itemRow.Name.ToDalamudString(); + if (hq) + // hq symbol + name.Payloads.Add(new TextPayload(" ")); + else if (payload.Kind == ItemKind.Collectible) + name.Payloads.Add(new TextPayload(" ")); + + _chunkRenderer.DrawChunks(ChunkUtil.ToChunks(name, ChunkSource.None, null).ToList(), false); + ImGui.Separator(); + + var realItemId = payload.RawItemId; + if (itemRow.EquipSlotCategory.RowId != 0) + { + if (ImGui.Selectable(Language.Context_TryOn)) + GameFunctions.Context.TryOn(realItemId, 0); + + if (ImGui.Selectable(Language.Context_ItemComparison)) + GameFunctions.Context.OpenItemComparison(realItemId); + } + + if (itemRow.ItemSearchCategory.Value.Category == 3) + if (ImGui.Selectable(Language.Context_SearchRecipes)) + GameFunctions.Context.SearchForRecipesUsingItem(payload.ItemId); + + if (ImGui.Selectable(Language.Context_SearchForItem)) + GameFunctions.Context.SearchForItem(realItemId); + + if (ImGui.Selectable(Language.Context_Link)) + GameFunctions.Context.LinkItem(realItemId); + + if (ImGui.Selectable(Language.Context_CopyItemName)) + ImGui.SetClipboardText(name.TextValue); + } + + private void DrawEventItemPopup(ItemPayload payload) + { + if (payload.Kind != ItemKind.EventItem) + return; + + if (!Sheets.EventItemSheet.HasRow(payload.ItemId)) + return; + + var item = Sheets.EventItemSheet.GetRow(payload.ItemId); + if ( + Plugin + .TextureProvider.GetFromGameIcon(new GameIconLookup(item.Icon)) + .GetWrapOrDefault() is + { } icon + ) + InlineIcon(icon); + + _chunkRenderer.DrawChunks( + ChunkUtil.ToChunks(item.Name.ToDalamudString(), ChunkSource.None, null).ToList(), + false + ); + ImGui.Separator(); + + var realItemId = payload.RawItemId; + if (ImGui.Selectable(Language.Context_Link)) + GameFunctions.Context.LinkItem(realItemId); + + if (ImGui.Selectable(Language.Context_CopyItemName)) + ImGui.SetClipboardText(item.Name.ToString()); + } + + private void DrawStatusPopup(StatusPayload status) + { + if ( + Plugin + .TextureProvider.GetFromGameIcon(new GameIconLookup(status.Status.Value.Icon)) + .GetWrapOrDefault() is + { } icon + ) + InlineIcon(icon); + + var builder = new SeStringBuilder(); + var nameValue = status.Status.Value.Name.ToString(); + switch (status.Status.Value.StatusCategory) + { + case 1: + builder.AddUiForeground($"{SeIconChar.Buff.ToIconString()}{nameValue}", 517); break; - case UriPayload uri: - DoHover(() => HoverUri(uri), hoverSize); + case 2: + builder.AddUiForeground($"{SeIconChar.Debuff.ToIconString()}{nameValue}", 518); break; + default: + builder.AddUiForeground(nameValue, 1); + break; + } + + _chunkRenderer.DrawChunks( + ChunkUtil.ToChunks(builder.BuiltString, ChunkSource.None, null).ToList(), + false + ); + ImGui.Separator(); + + if (ImGui.Selectable(Language.Context_Link)) + { + GameFunctions.Context.LinkStatus(status.Status.RowId); + _inputBar.AppendPending(" "); } } - private void DoHover(Action inside, float width) + private void DrawUriPopup(UriPayload uri) { - ImGui.SetNextWindowSize(new Vector2(width, -1f)); + ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority)); + ImGuiUtil.WarningText(Language.Context_URLWarning, false); + ImGui.Separator(); - using (ImRaii.Tooltip()) - using (ImRaii.TextWrapPos(0.0f)) - using (ImRaii.PushColor(ImGuiCol.Text, LogWindow.DefaultText)) - inside(); + if (ImGui.Selectable(Language.Context_OpenInBrowser)) + WrapperUtil.TryOpenUri(uri.Uri); + + if (ImGui.Selectable(Language.Context_CopyLink)) + { + ImGui.SetClipboardText(uri.Uri.ToString()); + WrapperUtil.AddNotification( + Language.Context_CopyLinkNotification, + NotificationType.Info + ); + } } public unsafe void MoveTooltip(AddonEvent type, AddonArgs args) { + // Defensive guard added in v1.7.1 — AddonLifecycle should never pass null, but be safe. + if (args == null) + { + _logger.LogWarning("MoveTooltip called with null AddonArgs — unexpected, skipping"); + return; + } + // Only move if the user has the "Next to Cursor" option selected if ( !Plugin.GameConfig.TryGet(UiControlOption.DetailTrackingType, out uint selected) @@ -284,7 +585,7 @@ public sealed class PayloadHandler ) return; - if (LogWindow.LastViewport != ImGuiHelpers.MainViewport.Handle) + if (_mainWindow.LastViewport != ImGuiHelpers.MainViewport.Handle) return; var atk = args.Addon; @@ -305,7 +606,10 @@ public sealed class PayloadHandler component.GetHeight() * component.GetScaleY() ); - var chatRect = new MathUtil.Rectangle(LogWindow.LastWindowPos, LogWindow.LastWindowSize); + var chatRect = new MathUtil.Rectangle( + _mainWindow.LastWindowPos, + _mainWindow.LastWindowSize + ); var addonRect = new MathUtil.Rectangle(atkPos, atkSize); if (!chatRect.HasOverlap(addonRect)) @@ -354,7 +658,7 @@ public sealed class PayloadHandler } // Spawning right/bottom of mouse cursor didn't solve the overlap, so we spawn it next to the chat - var x = isLeft ? chatRect.SizeX : LogWindow.LastWindowPos.X - atkSize.X; + var x = isLeft ? chatRect.SizeX : _mainWindow.LastWindowPos.X - atkSize.X; var y = Math.Clamp(chatRect.SizeY - atkSize.Y, 0, float.MaxValue); y -= isTop ? 0 : Plugin.Config.TooltipOffset; // offset to prevent cut-off on the bottom @@ -381,6 +685,59 @@ public sealed class PayloadHandler ); } + internal void Hover(Payload payload) + { + var hoverSize = 350f * ImGuiHelpers.GlobalScale; + + switch (payload) + { + case StatusPayload status: + DoHover(() => HoverStatus(status), hoverSize); + break; + case ItemPayload item: + // Native tooltip path: set state for MoveTooltip to reposition the game addon next frame. + if (Plugin.Config.NativeItemTooltips) + { + if (!HandleTooltips || HoveredItem != item.RawItemId) + { + HandleTooltips = true; + HoveredItem = item.RawItemId; + HoverCounter = LastHoverCounter = 0; + + GameFunctions.GameFunctions.OpenItemTooltip(item.RawItemId, item.Kind); + } + else + { + LastHoverCounter = HoverCounter; + } + + return; + } + + DoHover(() => HoverItem(item), hoverSize); + break; + case UriPayload uri: + DoHover(() => HoverUri(uri), hoverSize); + break; + } + } + + private void DoHover(Action drawAction, float tooltipWidth) + { + ImGui.SetNextWindowSize(new Vector2(tooltipWidth, -1f)); + + using (ImRaii.Tooltip()) + using (ImRaii.TextWrapPos(0.0f)) + using ( + // §4.2: use active theme text colour instead of the former LogWindow.DefaultText static. + ImRaii.PushColor( + ImGuiCol.Text, + ColourUtil.RgbaToVector4(_themes.Active.Colors.TextPrimary) + ) + ) + drawAction(); + } + private void HoverStatus(StatusPayload status) { if ( @@ -405,7 +762,7 @@ public sealed class PayloadHandler } var name = ChunkUtil.ToChunks(builder.BuiltString, ChunkSource.None, null); - LogWindow.DrawChunks(name.ToList()); + _chunkRenderer.DrawChunks(name.ToList()); ImGui.Separator(); var desc = ChunkUtil.ToChunks( @@ -413,7 +770,7 @@ public sealed class PayloadHandler ChunkSource.None, null ); - LogWindow.DrawChunks(desc.ToList()); + _chunkRenderer.DrawChunks(desc.ToList()); } private void HoverItem(ItemPayload item) @@ -424,19 +781,21 @@ public sealed class PayloadHandler return; } - if (!item.Item.TryGetValue(out Item resolvedItem)) + if (!Sheets.ItemSheet.TryGetRow(item.ItemId, out var resolvedItem)) return; if ( Plugin - .TextureProvider.GetFromGameIcon(new GameIconLookup(resolvedItem.Icon, item.IsHQ)) + .TextureProvider.GetFromGameIcon( + new GameIconLookup(resolvedItem.Icon, item.Kind == ItemKind.Hq) + ) .GetWrapOrDefault() is { } icon ) InlineIcon(icon); var name = ChunkUtil.ToChunks(resolvedItem.Name.ToDalamudString(), ChunkSource.None, null); - LogWindow.DrawChunks(name.ToList()); + _chunkRenderer.DrawChunks(name.ToList()); ImGui.Separator(); var desc = ChunkUtil.ToChunks( @@ -444,12 +803,12 @@ public sealed class PayloadHandler ChunkSource.None, null ); - LogWindow.DrawChunks(desc.ToList()); + _chunkRenderer.DrawChunks(desc.ToList()); } - private void HoverEventItem(ItemPayload payload) + private void HoverEventItem(ItemPayload item) { - if (!Sheets.EventItemSheet.TryGetRow(payload.RawItemId, out var itemRow)) + if (!Sheets.EventItemSheet.TryGetRow(item.RawItemId, out var itemRow)) return; if ( @@ -461,13 +820,13 @@ public sealed class PayloadHandler InlineIcon(icon); var name = ChunkUtil.ToChunks(itemRow.Name.ToDalamudString(), ChunkSource.None, null); - LogWindow.DrawChunks(name.ToList()); + _chunkRenderer.DrawChunks(name.ToList()); ImGui.Separator(); - if (!Sheets.EventItemHelpSheet.TryGetRow(payload.RawItemId, out var itemHelpRow)) + if (!Sheets.EventItemHelpSheet.TryGetRow(item.RawItemId, out var itemHelpRow)) return; - LogWindow.DrawChunks( + _chunkRenderer.DrawChunks( ChunkUtil .ToChunks(itemHelpRow.Description.ToDalamudString(), ChunkSource.None, null) .ToList() @@ -480,6 +839,22 @@ public sealed class PayloadHandler ImGuiUtil.WarningText(Language.Context_URLWarning); } + internal unsafe void Click(Chunk chunk, Payload? payload, ImGuiMouseButton button) + { + if (Plugin.Config.PlaySounds) + UIGlobals.PlaySoundEffect(PopupSfx); + + switch (button) + { + case ImGuiMouseButton.Left: + LeftClickPayload(chunk, payload); + break; + case ImGuiMouseButton.Right: + RightClickPayload(chunk, payload); + break; + } + } + private void LeftClickPayload(Chunk chunk, Payload? payload) { switch (payload) @@ -556,345 +931,7 @@ public sealed class PayloadHandler private void RightClickPayload(Chunk chunk, Payload? payload) { - Popup = (chunk, payload); + _popup = (chunk, payload); ImGui.OpenPopup(PopupId); } - - private void DrawItemPopup(ItemPayload payload) - { - if (payload.Kind == ItemKind.EventItem) - { - DrawEventItemPopup(payload); - return; - } - - if (!Sheets.ItemSheet.TryGetRow(payload.ItemId, out var itemRow)) - return; - - var hq = payload.Kind == ItemKind.Hq; - if ( - Plugin - .TextureProvider.GetFromGameIcon(new GameIconLookup(itemRow.Icon, hq)) - .GetWrapOrDefault() is - { } icon - ) - InlineIcon(icon); - - var name = itemRow.Name.ToDalamudString(); - // hq symbol - if (hq) - name.Payloads.Add(new TextPayload(" ")); - else if (payload.Kind == ItemKind.Collectible) - name.Payloads.Add(new TextPayload(" ")); - - LogWindow.DrawChunks(ChunkUtil.ToChunks(name, ChunkSource.None, null).ToList(), false); - ImGui.Separator(); - - var realItemId = payload.RawItemId; - if (itemRow.EquipSlotCategory.RowId != 0) - { - if (ImGui.Selectable(Language.Context_TryOn)) - GameFunctions.Context.TryOn(realItemId, 0); - - if (ImGui.Selectable(Language.Context_ItemComparison)) - GameFunctions.Context.OpenItemComparison(realItemId); - } - - if (itemRow.ItemSearchCategory.Value.Category == 3) - if (ImGui.Selectable(Language.Context_SearchRecipes)) - GameFunctions.Context.SearchForRecipesUsingItem(payload.ItemId); - - if (ImGui.Selectable(Language.Context_SearchForItem)) - GameFunctions.Context.SearchForItem(realItemId); - - if (ImGui.Selectable(Language.Context_Link)) - GameFunctions.Context.LinkItem(realItemId); - - if (ImGui.Selectable(Language.Context_CopyItemName)) - ImGui.SetClipboardText(name.TextValue); - } - - private void DrawEventItemPopup(ItemPayload payload) - { - if (payload.Kind != ItemKind.EventItem) - return; - - if (!Sheets.EventItemSheet.HasRow(payload.ItemId)) - return; - - var item = Sheets.EventItemSheet.GetRow(payload.ItemId); - if ( - Plugin - .TextureProvider.GetFromGameIcon(new GameIconLookup(item.Icon)) - .GetWrapOrDefault() is - { } icon - ) - InlineIcon(icon); - - LogWindow.DrawChunks( - ChunkUtil.ToChunks(item.Name.ToDalamudString(), ChunkSource.None, null).ToList(), - false - ); - ImGui.Separator(); - - var realItemId = payload.RawItemId; - if (ImGui.Selectable(Language.Context_Link)) - GameFunctions.Context.LinkItem(realItemId); - - if (ImGui.Selectable(Language.Context_CopyItemName)) - ImGui.SetClipboardText(item.Name.ToString()); - } - - private void DrawPlayerPopup(Chunk chunk, PlayerPayload player) - { - // Possible that GMs return a null payload - // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract - if (player == null) - return; - - var world = player.World; - if (chunk.Message?.Code.Type == ChatType.FreeCompanyLoginLogout) - if (Plugin.PlayerState.HomeWorld.IsValid) - world = Plugin.PlayerState.HomeWorld; - - var name = new List { new TextChunk(ChunkSource.None, null, player.PlayerName) }; - if (world.Value.IsPublic) - { - name.AddRange([ - new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld), - new TextChunk(ChunkSource.None, null, world.Value.Name.ExtractText()), - ]); - } - - LogWindow.DrawChunks(name, false); - ImGui.Separator(); - - var validContentId = chunk.Message?.ContentId is not (null or 0); - if (ImGui.Selectable(Language.Context_SendTell)) - { - // Eureka, Bozja and Occult need special handling as tells work different - if (!Sheets.IsInForay()) - { - LogWindow.Chat = $"/tell {player.PlayerName}"; - if (world.Value.IsPublic) - LogWindow.Chat += $"@{world.Value.Name}"; - - LogWindow.Chat += " "; - } - else if (validContentId) - { - LogWindow.Plugin.Functions.Chat.SetEurekaTellChannel( - player.PlayerName, - world.Value.Name.ToString(), - (ushort)world.RowId, - 0, - chunk.Message!.ContentId, - 0, - false - ); - } - - LogWindow.Activate = true; - } - - if (world.Value.IsPublic) - { - var party = Plugin.PartyList; - var leader = party[(int)party.PartyLeaderIndex]?.ContentId; - var isLeader = party.Length == 0 || Plugin.PlayerState.ContentId == leader; - var member = party.FirstOrDefault(member => - member.Name.TextValue == player.PlayerName && member.World.RowId == world.RowId - ); - var isInParty = member != null; - var inInstance = GameFunctions.GameFunctions.IsInInstance(); - var inPartyInstance = - Sheets - .TerritorySheet.GetRow(Plugin.ClientState.TerritoryType) - .TerritoryIntendedUse.RowId - is (41 or 47 or 48 or 52 or 53 or 61); - if (isLeader) - { - if (!isInParty) - { - if (inInstance && inPartyInstance) - { - if (validContentId && ImGui.Selectable(Language.Context_InviteToParty)) - GameFunctions.Party.InviteInInstance(chunk.Message!.ContentId); - } - else if (!inInstance) - { - using var menu = ImRaii.Menu(Language.Context_InviteToParty); - if (menu.Success) - { - if (ImGui.Selectable(Language.Context_InviteToParty_SameWorld)) - GameFunctions.Party.InviteSameWorld( - player.PlayerName, - (ushort)world.RowId, - chunk.Message?.ContentId ?? 0 - ); - - if ( - validContentId - && ImGui.Selectable(Language.Context_InviteToParty_DifferentWorld) - ) - GameFunctions.Party.InviteOtherWorld( - chunk.Message!.ContentId, - (ushort)world.RowId - ); - } - } - } - - if (isInParty && member != null && (!inInstance || (inInstance && inPartyInstance))) - { - if (ImGui.Selectable(Language.Context_Promote)) - GameFunctions.Party.Promote(player.PlayerName, member.ContentId); - - if (ImGui.Selectable(Language.Context_KickFromParty)) - GameFunctions.Party.Kick(player.PlayerName, member.ContentId); - } - } - - var isFriend = GameFunctions - .GameFunctions.GetFriends() - .Any(friend => - friend.NameString == player.PlayerName && friend.HomeWorld == world.RowId - ); - if (!isFriend && ImGui.Selectable(Language.Context_SendFriendRequest)) - LogWindow.Plugin.Functions.SendFriendRequest( - player.PlayerName, - (ushort)world.RowId - ); - - using (var menuBlockFunctions = ImRaii.Menu(Language.Context_BlockFunctions)) - { - if (menuBlockFunctions.Success) - { - if (ImGui.Selectable(Language.Context_AddToBlacklist)) - LogWindow.Plugin.Functions.AddToBlacklist( - player.PlayerName, - (ushort)world.RowId - ); - - if (chunk.Message != null) - { - var message = chunk.Message; - - if ( - message.AccountId != 0 - && ImGui.Selectable(Language.Context_AddToMuteList) - ) - LogWindow.Plugin.Functions.AddToMuteList( - message.AccountId, - message.ContentId, - player.PlayerName, - (short)world.RowId - ); - - if (ImGui.Selectable(Language.Context_AddToTermsFilter)) - LogWindow.Plugin.Functions.AddToTermsList(message.ContentSource); - } - } - } - - if ( - GameFunctions.GameFunctions.IsMentor() - && ImGui.Selectable(Language.Context_InviteToNoviceNetwork) - ) - GameFunctions.Context.InviteToNoviceNetwork(player.PlayerName, (ushort)world.RowId); - } - - var inputChannel = chunk.Message?.Code.Type.ToInputChannel(); - if (inputChannel != null && ImGui.Selectable(Language.Context_ReplyInSelectedChatMode)) - { - LogWindow.SetChannel(inputChannel.Value); - LogWindow.Activate = true; - } - - if (ImGui.Selectable(Language.Context_Target) && FindCharacterForPayload(player) is { } obj) - Plugin.TargetManager.Target = obj; - - if (validContentId && ImGui.Selectable(Language.Context_AdventurerPlate)) - if (!GameFunctions.GameFunctions.TryOpenAdventurerPlate(chunk.Message!.ContentId)) - WrapperUtil.AddNotification( - Language.Context_AdventurerPlateError, - NotificationType.Warning - ); - } - - private IPlayerCharacter? FindCharacterForPayload(PlayerPayload payload) - { - foreach (var obj in Plugin.ObjectTable) - { - if (obj is not IPlayerCharacter character) - continue; - - if (character.Name.TextValue != payload.PlayerName) - continue; - - if (payload.World.Value.IsPublic && character.HomeWorld.RowId != payload.World.RowId) - continue; - - return character; - } - - return null; - } - - private void DrawUriPopup(UriPayload uri) - { - ImGui.TextUnformatted(string.Format(Language.Context_URLDomain, uri.Uri.Authority)); - ImGuiUtil.WarningText(Language.Context_URLWarning, false); - ImGui.Separator(); - - if (ImGui.Selectable(Language.Context_OpenInBrowser)) - WrapperUtil.TryOpenUri(uri.Uri); - - if (ImGui.Selectable(Language.Context_CopyLink)) - { - ImGui.SetClipboardText(uri.Uri.ToString()); - WrapperUtil.AddNotification( - Language.Context_CopyLinkNotification, - NotificationType.Info - ); - } - } - - private void DrawStatusPopup(StatusPayload status) - { - if ( - Plugin - .TextureProvider.GetFromGameIcon(new GameIconLookup(status.Status.Value.Icon)) - .GetWrapOrDefault() is - { } icon - ) - InlineIcon(icon); - - var builder = new SeStringBuilder(); - var nameValue = status.Status.Value.Name.ToString(); - switch (status.Status.Value.StatusCategory) - { - case 1: - builder.AddUiForeground($"{SeIconChar.Buff.ToIconString()}{nameValue}", 517); - break; - case 2: - builder.AddUiForeground($"{SeIconChar.Debuff.ToIconString()}{nameValue}", 518); - break; - default: - builder.AddUiForeground(nameValue, 1); - break; - } - - LogWindow.DrawChunks( - ChunkUtil.ToChunks(builder.BuiltString, ChunkSource.None, null).ToList(), - false - ); - ImGui.Separator(); - - if (ImGui.Selectable(Language.Context_Link)) - { - GameFunctions.Context.LinkStatus(status.Status.RowId); - LogWindow.Chat += " "; - } - } } diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 5c090a4..cc2a810 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -91,18 +91,26 @@ public sealed class Plugin : IAsyncDalamudPlugin public static Configuration Config = null!; public static FileDialogManager FileDialogManager { get; private set; } = null!; + // Single static handle to the live Plugin instance. Lets statically-accessed + // UI helpers (TabContextMenu) reach instance-only members — SaveConfig(), + // AutoTellTabsService, CustomAudioPlayer — without ctor-injection. A per-member + // static accessor is impossible: it would collide by name with the instance + // property (CS0102). Filled in the post-resolve bridge block below. + internal static Plugin Instance = null!; + public readonly WindowSystem WindowSystem = new(PluginName); // Phase-2 services are constructed in LoadAsync; null! shape is kept // consistent across all properties for clarity. - public SettingsWindow SettingsWindow { get; private set; } = null!; - public ChatLogWindow ChatLogWindow { get; private set; } = null!; + internal Ui.Windows.MainWindow MainWindow { get; private set; } = null!; + internal Ui.Windows.SettingsWindow SettingsWindow { get; private set; } = null!; + internal Ui.Windows.ChannelPopoutPool ChannelPopoutPool { get; private set; } = null!; public DbViewer DbViewer { get; private set; } = null!; - public InputPreview InputPreview { get; private set; } = null!; - public CommandHelpWindow CommandHelpWindow { get; private set; } = null!; + internal static InputPreview InputPreview { get; private set; } = null!; + internal CommandHelpWindow CommandHelpWindow { get; private set; } = null!; public SeStringDebugger SeStringDebugger { get; private set; } = null!; public FirstRunWizard FirstRunWizard { get; private set; } = null!; - public DebuggerWindow DebuggerWindow { get; private set; } = null!; + internal DebuggerWindow DebuggerWindow { get; private set; } = null!; internal Commands Commands { get; private set; } = null!; internal GameFunctions.GameFunctions Functions { get; private set; } = null!; @@ -111,12 +119,20 @@ public sealed class Plugin : IAsyncDalamudPlugin internal IpcManager Ipc { get; private set; } = null!; internal ExtraChat ExtraChat { get; private set; } = null!; internal TypingIpc TypingIpc { get; private set; } = null!; + internal Ui.Components.InputBar InputBar { get; private set; } = null!; internal FontManager FontManager { get; private set; } = null!; internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!; - internal Ui.StatusBar StatusBar { get; private set; } = null!; internal Integrations.HonorificService HonorificService { get; private set; } = null!; internal Integrations.CustomAudioPlayer CustomAudioPlayer { get; private set; } = null!; + // Ctor-smoke anchors (B0-2). Exposed so the Payload/Chunk ctor-smoke steps + // can drive the real per-frame Lender path (Borrow()) and the eager + // singletons through the container, never via new(). Mirror of the + // FontManager property pattern — every SelfTest reaches services this way. + internal PayloadHandler PayloadHandler { get; private set; } = null!; + internal Util.Lender PayloadHandlerLender { get; private set; } = null!; + internal Ui.Components.ChunkRenderer ChunkRenderer { get; private set; } = null!; + // Platform indirection over Dalamud.Utility.Util. Wired in Phase-1 ctor so // any service allocated in LoadAsync can read Plugin.PlatformUtil. internal static IPlatformUtil PlatformUtil { get; private set; } = null!; @@ -134,6 +150,7 @@ public sealed class Plugin : IAsyncDalamudPlugin // Wrapper cached so TearDown can detach the live instance instead of // re-registering with identical args (v1.4.9 ISSUE-1 cleanup). private CommandWrapper? _hellionSettingsCmd; + private CommandWrapper? _clearHellionCmd; private CommandWrapper? _hellionViewCmd; private CommandWrapper? _hellionDebuggerCmd; #if DEBUG @@ -165,17 +182,12 @@ public sealed class Plugin : IAsyncDalamudPlugin internal DateTime GameStarted { get; } - // Tab management lives here rather than in ChatLogWindow for access reasons. - internal int LastTab { get; set; } - internal int? WantedTab { get; set; } - internal Tab CurrentTab - { - get - { - var i = LastTab; - return i > -1 && i < Config.Tabs.Count ? Config.Tabs[i] : new Tab(); - } - } + // Couples "current tab" to the real UI selection. The chat hooks are + // installed before MainWindow is Phase-1 resolved, so the null-conditional + // fallback to Tabs[0] is load-bearing — it keeps the pre-coupling behavior + // in that early window rather than being merely defensive. + internal Tab CurrentTab => + MainWindow?.ActiveTab ?? (Config.Tabs.Count > 0 ? Config.Tabs[0] : new Tab()); public Plugin() { @@ -200,11 +212,12 @@ public sealed class Plugin : IAsyncDalamudPlugin // do not touch either static, so the brief null-window is safe. // Schema gate: v1.4.x+ requires config v16+. Users on older schemas - // must install v1.4.2 first to run the migration chain. v19 adds the + // must install v1.4.2 first to run the migration chain. v19 added the // top-level CustomSoundVolume, WindowOpacityInactive, WorldSuffixMode - // and NameFormMode fields — all additive with defaults, so v16-v18 - // configs load cleanly and get their Version stamp bumped after the - // gate. + // and NameFormMode fields; v20 adds MainWindowOpen, SettingsWindowOpen, + // MaxParallelPopouts, TellAutoOpenMode and SidebarAutoSwitchThresholdPx + // — all additive with defaults, so v16-v19 configs load cleanly and + // get their Version stamp bumped after the gate. if (Config.Version < 16) { throw new InvalidOperationException( @@ -212,7 +225,17 @@ public sealed class Plugin : IAsyncDalamudPlugin + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.10." ); } - Config.Version = 19; + // v23 migration: SidebarTabView was the 1.5.6 sidebar↔top-tabs switch, + // superseded by MainWindowLayoutMode in the v1.6.0 rewrite. A user who + // set it false (only effective in 1.5.6) wanted top tabs — carry that + // intent forward. Runs only for pre-v23 configs; fresh configs load at + // LatestVersion and skip it. Additive v20/v22 fields keep their + // initializer defaults as before. + if (Config.Version < 23 && !Config.SidebarTabView) + { + Config.MainWindowLayoutMode = MainWindowLayoutMode.TopTabs; + } + Config.Version = 23; // Unpinned TempTabs are session-only and dropped on every load. Pinned // TempTabs survive reload — Jin's tester feedback (v1.4.7). @@ -268,6 +291,10 @@ public sealed class Plugin : IAsyncDalamudPlugin ); _host = PluginHostFactory.Build(this, dependencies); + + // Bridge the static handle before the instance members below are read. + Instance = this; + _lifecycle = _host.Services.GetRequiredService(); _lifecycle.Host = _host; @@ -288,18 +315,29 @@ public sealed class Plugin : IAsyncDalamudPlugin ExtraChat = _host.Services.GetRequiredService(); HonorificService = _host.Services.GetRequiredService(); CustomAudioPlayer = _host.Services.GetRequiredService(); - StatusBar = _host.Services.GetRequiredService(); MessageManager = _host.Services.GetRequiredService(); AutoTellTabsService = _host.Services.GetRequiredService(); - ChatLogWindow = _host.Services.GetRequiredService(); - SettingsWindow = _host.Services.GetRequiredService(); + InputBar = _host.Services.GetRequiredService(); + MainWindow = _host.Services.GetRequiredService(); + SettingsWindow = _host.Services.GetRequiredService(); DbViewer = _host.Services.GetRequiredService(); InputPreview = _host.Services.GetRequiredService(); CommandHelpWindow = _host.Services.GetRequiredService(); SeStringDebugger = _host.Services.GetRequiredService(); DebuggerWindow = _host.Services.GetRequiredService(); FirstRunWizard = _host.Services.GetRequiredService(); + ChannelPopoutPool = _host.Services.GetRequiredService(); + + // Ctor-smoke anchors (B0-2). Resolved last, against the fully built + // container: every MakePayloadHandler dep (MainWindow, InputBar, + // ChunkRenderer, ...) is resolvable here, and the ChunkRenderer resolve + // below just reuses the same cached singleton. These are plain + // post-build container resolves (no new factory-lambda edge) — they add + // no DI cycle. See feedback_di_factory_callsite_cycles. + PayloadHandler = _host.Services.GetRequiredService(); + PayloadHandlerLender = _host.Services.GetRequiredService>(); + ChunkRenderer = _host.Services.GetRequiredService(); } public async Task LoadAsync(CancellationToken cancellationToken) @@ -336,10 +374,41 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.ThemeSwitchSelfTestStep(this), new SelfTests.ThemeCrossfadeSelfTestStep(this), new SelfTests.FontManagerCtorSmokeStep(this), + new SelfTests.PayloadHandlerCtorSmokeStep(this), + new SelfTests.ChunkRendererCtorSmokeStep(this), new SelfTests.FontPushSmokeStep(this), new SelfTests.WizardStateSmokeStep(this), - new SelfTests.QuickPickerSelfTestStep(this), new SelfTests.FoxBannerTextureSmokeStep(this), + new SelfTests.SidebarModeAutoSwitchStep(this), + new SelfTests.ColorEditorBufferStep(this), + new SelfTests.ThemePickerCategoryStep(this), + new SelfTests.QuickPickerSelfTestStep(this), + new SelfTests.HideRestoreSelfTestStep(this), + new SelfTests.SettingsWindowOpenStep(this), + new SelfTests.OnOpenMainUiRoutesMainWindowStep(this), + new SelfTests.TypingIpcStateStep(this), + new SelfTests.ConfigMigrationV23Step(this), + new SelfTests.ChannelPopoutBindStep(this), + new SelfTests.HoverSheenAllocStep(this), + new SelfTests.HonorificHeaderRenderStep(this), + new SelfTests.AboutIntegrationsStatusStep(this), + new SelfTests.PerformanceBaselineStep(this), + new SelfTests.MainWindowFocusOpacityStep(this), + new SelfTests.MainWindowFlagsStep(this), + new SelfTests.SenderNameReformatStep(this), + new SelfTests.DisclosureArmStep(this), + new SelfTests.TellRoutingBuildStep(this), + new SelfTests.TellPillTransparencyStep(this), + new SelfTests.TabRenamePersistStep(this), + new SelfTests.NotificationSoundSelectStep(), + new SelfTests.SidebarGreetedGlyphStep(this), + new SelfTests.SidebarSectionHeaderStep(this), + new SelfTests.ScrollSnapDecisionStep(this), + new SelfTests.TellResetOnActivateStep(), + new SelfTests.CurrentTabCouplingStep(this), + new SelfTests.SidebarUnreadDotStep(this), + new SelfTests.UnreadDecisionStep(), + new SelfTests.CurrentTabGuidedStep(this), ]); // Re-surface the wizard for existing users when a major UX @@ -743,14 +812,15 @@ public sealed class Plugin : IAsyncDalamudPlugin // have working entry points before they're constructed. private void SetupCommands() { - // ChatLogWindow.cs:128 already registers /hellion (ToggleChat). The - // description-arg here keeps the Dalamud help list populated. _hellionSettingsCmd = Commands.Register( "/hellion", - "Perform various actions with Hellion Chat." + "Toggle Hellion Chat. /hellion settings opens settings, /hellion reset restores the default theme." ); _hellionSettingsCmd.Execute += OnHellionSettingsCommand; + _clearHellionCmd = Commands.Register("/clearhellion", "Clear the active Hellion Chat tab."); + _clearHellionCmd.Execute += OnClearHellionCommand; + _hellionViewCmd = Commands.Register( "/hellionView", "Get access to your message history, with simple filter options.", @@ -787,6 +857,12 @@ public sealed class Plugin : IAsyncDalamudPlugin _hellionSettingsCmd = null; } + if (_clearHellionCmd is not null) + { + _clearHellionCmd.Execute -= OnClearHellionCommand; + _clearHellionCmd = null; + } + if (_hellionViewCmd is not null) { _hellionViewCmd.Execute -= OnHellionViewCommand; @@ -809,15 +885,34 @@ public sealed class Plugin : IAsyncDalamudPlugin private void OnHellionSettingsCommand(string command, string arguments) { - // /hellion with args is intentionally a no-op (matches pre-v1.4.9 - // Settings.cs:76-80 behaviour). - if (string.IsNullOrWhiteSpace(arguments)) + var arg = arguments.Trim(); + if (string.IsNullOrEmpty(arg)) + { + MainWindow.Toggle(); + return; + } + if (arg.Equals("settings", StringComparison.OrdinalIgnoreCase)) + { SettingsWindow.Toggle(); + return; + } + if (arg.Equals("reset", StringComparison.OrdinalIgnoreCase)) + { + // Recovery path documented in the v2.x master spec — drops a + // broken custom theme out of the loader cache without touching + // the user's JSON on disk. + ThemeRegistry.SwitchSilent(Themes.ThemeRegistry.DefaultSlug); + } + } + + private void OnClearHellionCommand(string command, string arguments) + { + MainWindow.ActiveTab?.Clear(); } private void OnOpenConfigUi() => SettingsWindow.Toggle(); - private void OnOpenMainUi() => SettingsWindow.Toggle(); + private void OnOpenMainUi() => MainWindow.Toggle(); private void OnHellionViewCommand(string _, string __) => DbViewer.Toggle(); @@ -916,18 +1011,14 @@ public sealed class Plugin : IAsyncDalamudPlugin // free on built-in themes and ~1 stat/second on custom themes. ThemeRegistry.RefreshActiveIfStale(); - // Theme engine is always active; Classic is a theme, not a disabled state. - using IDisposable _style = HellionStyle.PushGlobal( + using IDisposable _style = Ui.StyleEngine.GlobalStyleScope.Push( ThemeRegistry.Active, ThemeRegistry, Config.WindowOpacity ); - ChatLogWindow.BeginFrame(); - if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas]) { - ChatLogWindow.FinalizeFrame(); TypingIpc.Update(); return; } @@ -940,28 +1031,19 @@ public sealed class Plugin : IAsyncDalamudPlugin ) ) { - ChatLogWindow.FinalizeFrame(); TypingIpc.Update(); return; } - ChatLogWindow.HideStateCheck(); - Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden; - ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int)ImGuiCol.Text]; // RegularFont is nullable only because the live rebuild path // disposes it before reassigning; both ends of that swap happen on // this same draw thread, so it cannot be null here. - // v1.5.3 fix: also push RegularFont when the bundled Inter Light is - // selected. Without this, UseHellionFont=true silently fell back to - // the FFXIV Axis font because the Appearance tab forces FontsEnabled - // off in that branch, and the bundled font never made it into draw. var useRegularFont = Config.FontsEnabled || Config.UseHellionFont; using ((useRegularFont ? FontManager.RegularFont! : FontManager.Axis).Push()) WindowSystem.Draw(); - ChatLogWindow.FinalizeFrame(); TypingIpc.Update(); FileDialogManager.Draw(); diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index 62ed716..06a9c32 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -29,6 +29,15 @@ internal static class PluginHostFactory logging.AddDalamudLogging(dependencies.PluginLog); logging.SetMinimumLevel(LogLevel.Trace); }) + // ValidateOnBuild eagerly instantiates every singleton at Build time + // so missing registrations / ConstructorCallSite cycles throw on + // load instead of producing a silent hang. ValidateScopes is cheap + // (we only use singletons) but guards against future Scoped misuse. + .UseDefaultServiceProvider(o => + { + o.ValidateOnBuild = true; + o.ValidateScopes = true; + }) .ConfigureServices(services => ConfigureServices(services, plugin, dependencies)) .Build(); } @@ -80,7 +89,6 @@ internal static class PluginHostFactory services.AddSingleton(sp => new FontManager( sp.GetRequiredService() )); - services.AddSingleton(_ => new StatusBar()); services.AddSingleton(sp => new IpcManager(sp.GetRequiredService>())); services.AddSingleton(sp => new ExtraChat(sp.GetRequiredService>())); @@ -92,6 +100,11 @@ internal static class PluginHostFactory sp.GetRequiredService>() )); + services.AddSingleton(_ => new Ui.StyleEngine.TokenResolver()); + services.AddSingleton(sp => new Ui.StyleEngine.PushStack( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new GameFunctions.GameFunctions( sp.GetRequiredService(), sp.GetRequiredService>(), @@ -99,6 +112,7 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new TypingIpc( sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>() )); @@ -107,6 +121,118 @@ internal static class PluginHostFactory sp.GetRequiredService>(), sp.GetRequiredService() )); + services.AddSingleton(sp => new Services.TellRouterService( + sp.GetRequiredService(), + sp.GetRequiredService>() + )); + + services.AddSingleton(sp => new Ui.Components.HonorificHeader( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Sidebar( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.MessageList( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(_ => new Ui.Components.SymbolPicker()); + services.AddSingleton(sp => new Ui.Components.ThemeQuickPicker( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.InputBar( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + () => sp.GetRequiredService().SettingsWindow.Toggle(), + sp.GetRequiredService(), + sp.GetRequiredService(), + () => sp.GetRequiredService().MainWindow.UserHide() + )); + services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.ContentArea()); + services.AddSingleton(sp => new Ui.Components.Settings.ThemePicker( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.ColorPicker( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.LivePreviewPanel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.ThemeImportExportRow( + sp.GetRequiredService(), + sp.GetRequiredService>() + )); + services.AddSingleton(sp => new Ui.Components.Settings.FontsSection( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.ChatColourPicker( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AppearanceTab( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.Tabs.GeneralTab( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChatTab( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.Tabs.WindowTab( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChannelsTab( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.Tabs.DataPrivacyTab( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AboutTab( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.StatusBar( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Components.TopTabBar( + sp.GetRequiredService() + )); + services.AddSingleton(sp => new Ui.Windows.MainWindow( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>() + )); services.AddSingleton(sp => new Integrations.FailedTellNotifier( sp.GetRequiredService>() )); @@ -134,25 +260,89 @@ internal static class PluginHostFactory ); }); + // Factory-lambdas for ChunkRenderer, PayloadHandler, and Lender + // because all three are internal-sealed (ActivatorUtilities can't reflect into + // internal ctors) and Lender has an internal ctor by design. + // PayloadHandler registered twice: once as singleton for G/H, once via Lender for per-frame isolation (I/J/K). + services.AddSingleton(sp => new Ui.Components.ChunkRenderer( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService() + )); + services.AddSingleton(sp => MakePayloadHandler(sp)); + services.AddSingleton(sp => new Lender(() => MakePayloadHandler(sp))); + + // Pop-out windows: each gets its OWN MessageList + InputBar so the + // channel pill and message scroll are per-window. The PayloadHandler is + // attached post-build (ChannelPopoutInitHostedService), NEVER via ctor + // (plan §B.2 — would close a silent FactoryCallSite cycle). + services.AddSingleton>(sp => + slot => new Ui.Windows.ChannelPopoutWindow( + slot, + new Ui.Components.MessageList( + sp.GetRequiredService(), + sp.GetRequiredService() + ), + new Ui.Components.InputBar( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>(), + () => sp.GetRequiredService().SettingsWindow.Toggle(), + sp.GetRequiredService() + ), + sp.GetRequiredService>(), + sp.GetRequiredService() + ) + ); + services.AddSingleton(sp => new Ui.Windows.ChannelPopoutPool( + sp.GetRequiredService>(), + sp.GetRequiredService>() + )); + // Block C — Windows. WindowSystem.AddWindow is called from // PluginLifecycle.LoadAsync on the framework thread. - services.AddSingleton(sp => new ChatLogWindow( - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService() - )); - services.AddSingleton(sp => new SettingsWindow( + services.AddSingleton(sp => new Ui.Windows.SettingsWindow( sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService() )); services.AddSingleton(sp => new DbViewer( sp.GetRequiredService(), sp.GetRequiredService>() )); - services.AddSingleton(sp => new InputPreview(sp.GetRequiredService())); - services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new InputPreview( + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>() + )); + // No MainWindow ctor-param: breaks the InputBar -> CommandHelpWindow -> + // MainWindow -> InputBar singleton cycle. MainWindow is wired post-build + // via CommandHelpWindowInitHostedService. + services.AddSingleton(sp => new CommandHelpWindow( + sp.GetRequiredService(), + sp.GetRequiredService>() + )); services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService())); - services.AddSingleton(sp => new DebuggerWindow(sp.GetRequiredService())); + services.AddSingleton(sp => new DebuggerWindow( + sp.GetRequiredService(), + sp.GetRequiredService() + )); services.AddSingleton(sp => new FirstRunWizard(sp.GetRequiredService())); // Hosted-service adapters: thin wrappers around the existing init @@ -160,7 +350,8 @@ internal static class PluginHostFactory // does not need one — its ctor runs the init inline inside a single // SuppressAutoRebuild block on eager resolve. services.AddHostedService(sp => new ThemeRegistryInitHostedService( - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddHostedService(sp => new IpcManagerInitHostedService( sp.GetRequiredService() @@ -178,12 +369,41 @@ internal static class PluginHostFactory services.AddHostedService(sp => new AutoTellTabsServiceInitHostedService( sp.GetRequiredService() )); + // Must come AFTER AutoTell's registration: both subscribe MessageProcessed, + // and AutoTell subscribing first lets the router's IsOpen-guard see the + // already-opened pop-out (FIFO framework-tick ordering, no double-pop). + services.AddHostedService(sp => new TellRouterServiceInitHostedService( + sp.GetRequiredService() + )); services.AddHostedService( sp => new Infrastructure.Hosting.FailedTellNotifierInitHostedService( sp.GetRequiredService() ) ); + services.AddHostedService(sp => new PayloadHandlerInitHostedService( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddHostedService(sp => new CommandHelpWindowInitHostedService( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddHostedService(sp => new ChannelPopoutInitHostedService( + sp.GetRequiredService(), + sp.GetRequiredService() + )); } + + private static PayloadHandler MakePayloadHandler(IServiceProvider sp) => + new( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>() + ); } internal sealed record PluginHostDependencies( diff --git a/HellionChat/PluginLifecycle.cs b/HellionChat/PluginLifecycle.cs index 052fdf8..369bd24 100644 --- a/HellionChat/PluginLifecycle.cs +++ b/HellionChat/PluginLifecycle.cs @@ -58,14 +58,19 @@ internal sealed class PluginLifecycle : IAsyncDisposable private static void RegisterWindows(Plugin plugin) { - plugin.WindowSystem.AddWindow(plugin.ChatLogWindow); + plugin.WindowSystem.AddWindow(plugin.MainWindow); plugin.WindowSystem.AddWindow(plugin.SettingsWindow); plugin.WindowSystem.AddWindow(plugin.DbViewer); - plugin.WindowSystem.AddWindow(plugin.InputPreview); + plugin.WindowSystem.AddWindow(Plugin.InputPreview); plugin.WindowSystem.AddWindow(plugin.CommandHelpWindow); plugin.WindowSystem.AddWindow(plugin.SeStringDebugger); plugin.WindowSystem.AddWindow(plugin.DebuggerWindow); plugin.WindowSystem.AddWindow(plugin.FirstRunWizard); + + // Pop-out pool: register all pre-allocated instances ONCE here on the + // framework thread. Open/Close at runtime is IsOpen-only, never AddWindow. + foreach (var popout in plugin.ChannelPopoutPool.Instances) + plugin.WindowSystem.AddWindow(popout); } public async ValueTask DisposeAsync() diff --git a/HellionChat/SelfTests/AboutIntegrationsStatusStep.cs b/HellionChat/SelfTests/AboutIntegrationsStatusStep.cs new file mode 100644 index 0000000..c7e9014 --- /dev/null +++ b/HellionChat/SelfTests/AboutIntegrationsStatusStep.cs @@ -0,0 +1,101 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Integrations; + +namespace HellionChat.SelfTests; + +// Verifies the About-tab integrations status. The pure HonorificStatus.Resolve +// covers the three-state mapping (false-green-free); driving the real AboutTab +// render once proves the render path actually calls the resolver (sets +// LastHonorificStatusKey). Set -> Draw -> Assert happen in ONE synchronous +// RunStep so a between-frame Honorific IPC callback can't clobber the seam +// state; the prior service state is restored in CleanUp. +internal sealed class AboutIntegrationsStatusStep : ISelfTestStep +{ + private readonly Plugin plugin; + + private HonorificService? _svc; + private bool _prevAvailable; + private (uint Major, uint Minor)? _prevVersion; + private HonorificTitleData? _prevTitle; + private bool _snapshotted; + + public AboutIntegrationsStatusStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - About integrations status"; + + public SelfTestStepResult RunStep() + { + // AboutTab.Draw renders DrawBrand/coming-soon under _fonts.FontAwesome.Push; + // wait until the atlas is built so the render can't misbehave. Returned + // BEFORE any snapshot/Set, so no seam state leaks (same guard as the header + // step; precedent FoxBannerTextureSmokeStep). + if (!plugin.FontManager.FontsReady) + { + return SelfTestStepResult.Waiting; + } + + // Pure mapping (incl. the isAvailable=true + null boundary -> NotInstalled). + if ( + HonorificStatus.Resolve(true, (3, 1)) != HonorificStatusKind.Detected + || HonorificStatus.Resolve(false, (2, 5)) != HonorificStatusKind.Incompatible + || HonorificStatus.Resolve(false, null) != HonorificStatusKind.NotInstalled + || HonorificStatus.Resolve(true, null) != HonorificStatusKind.NotInstalled + ) + { + ImGui.Text("HonorificStatus.Resolve mapping is wrong"); + return SelfTestStepResult.Fail; + } + + var about = plugin.SettingsWindow.GetAboutTabForSelfTest(); + if (about is null) + { + ImGui.Text("SettingsWindow.AboutTab reference is null"); + return SelfTestStepResult.Fail; + } + + _svc = plugin.MainWindow.GetHonorificHeaderForSelfTest()?.GetServiceForSelfTest(); + if (_svc is null) + { + ImGui.Text("HonorificService reference is null"); + return SelfTestStepResult.Fail; + } + + _prevAvailable = _svc.IsAvailable; + _prevVersion = _svc.DetectedApiVersion; + _prevTitle = _svc.CurrentTitle; + _snapshotted = true; + + try + { + // Drive the real render once and confirm the resolver is wired in. + _svc.TestOnly_SetState(true, (3, 1), null); + about.Draw(); + if (about.LastHonorificStatusKey != HonorificStatusKind.Detected.ToString()) + { + ImGui.Text( + $"About render did not resolve Detected (got {about.LastHonorificStatusKey})" + ); + return SelfTestStepResult.Fail; + } + } + catch (Exception ex) + { + ImGui.Text($"AboutTab.Draw threw: {ex.GetType().Name}: {ex.Message}"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() + { + if (!_snapshotted || _svc is null) + return; + _svc.TestOnly_SetState(_prevAvailable, _prevVersion, _prevTitle); + _snapshotted = false; + } +} diff --git a/HellionChat/SelfTests/ChannelPopoutBindStep.cs b/HellionChat/SelfTests/ChannelPopoutBindStep.cs new file mode 100644 index 0000000..9965110 --- /dev/null +++ b/HellionChat/SelfTests/ChannelPopoutBindStep.cs @@ -0,0 +1,115 @@ +using System.Linq; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// In-game behavioural check of the ChannelPopoutPool lifecycle (not a non-null-handle +// check — feedback_hellion_chat_fontmanager_push_trap): pre-alloc count, unique slot +// ids, a TryOpen->IsOpen->TryClose round-trip, idempotent close, and capacity refusal. +// The pool is a live DI singleton, so the step works against the FREE slots (not full +// capacity) and only closes ids it opened — it neither false-REDs on a non-empty pool +// nor disturbs real pop-outs. Pure slot-map math is pinned by PopoutSlotMapTests. +internal sealed class ChannelPopoutBindStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public ChannelPopoutBindStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - Channel popout pool lifecycle"; + + public SelfTestStepResult RunStep() + { + var pool = _plugin.ChannelPopoutPool; + var capacity = Plugin.Config.MaxParallelPopouts; + + if (pool.Instances.Count != capacity) + { + ImGui.Text( + $"Expected {capacity} pre-allocated pop-out windows, found {pool.Instances.Count}." + ); + return SelfTestStepResult.Fail; + } + + if (pool.Instances.Select(w => w.SlotIndex).Distinct().Count() != pool.Instances.Count) + { + ImGui.Text("Pop-out windows do not have unique slot indices."); + return SelfTestStepResult.Fail; + } + + // Free slots right now = capacity minus whatever real pop-outs are already + // bound. Testing against this (not capacity) keeps the step state-independent. + var free = capacity - pool.Instances.Count(w => w.Bound is not null); + + // Round-trip on a throwaway tab, only when there's a slot to take. A bare Tab + // has CurrentChannel.Channel == Invalid + an empty SelectedChannels, so the + // pool's OnTabActivated strip is a no-op (no NRE), and the live active tab is + // passed only as `previous`, so it is never mutated. We close before + // returning, so the bound window never reaches a Draw frame. + if (free > 0) + { + var probe = new Tab { Name = "##selftest-popout-probe" }; + if (pool.IsOpen(probe.Identifier)) + { + ImGui.Text("Probe tab already open before TryOpen."); + return SelfTestStepResult.Fail; + } + + if (!pool.TryOpen(probe)) + { + ImGui.Text("TryOpen returned false with a free slot."); + return SelfTestStepResult.Fail; + } + + if (!pool.IsOpen(probe.Identifier)) + { + ImGui.Text("IsOpen is false right after a successful TryOpen."); + pool.TryClose(probe.Identifier); + return SelfTestStepResult.Fail; + } + + pool.TryClose(probe.Identifier); + if (pool.IsOpen(probe.Identifier)) + { + ImGui.Text("IsOpen is still true after TryClose."); + return SelfTestStepResult.Fail; + } + + // Idempotent: closing an already-closed id is a silent no-op. + pool.TryClose(probe.Identifier); + } + + // Capacity guard: fill the remaining free slots, then one more open must be + // refused (warn, no throw). Release everything we opened before reporting. + var fillers = Enumerable + .Range(0, free) + .Select(_ => new Tab { Name = "##selftest-fill" }) + .ToList(); + var opened = fillers.Count(pool.TryOpen); + var overflow = new Tab { Name = "##selftest-overflow" }; + var overflowRejected = !pool.TryOpen(overflow); + + foreach (var filler in fillers) + pool.TryClose(filler.Identifier); + pool.TryClose(overflow.Identifier); + + if (opened != free) + { + ImGui.Text($"Filled only {opened}/{free} free slots before TryOpen refused."); + return SelfTestStepResult.Fail; + } + + if (!overflowRejected) + { + ImGui.Text("Pool accepted an open beyond capacity instead of refusing."); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/ChunkRendererCtorSmokeStep.cs b/HellionChat/SelfTests/ChunkRendererCtorSmokeStep.cs new file mode 100644 index 0000000..304bcf4 --- /dev/null +++ b/HellionChat/SelfTests/ChunkRendererCtorSmokeStep.cs @@ -0,0 +1,37 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// ChunkRenderer is a plain singleton (PluginHostFactory.cs:247) consumed by the +// real render path (MainWindow/MessageList/InputPreview DrawChunks). One +// resolution path is enough — unlike PayloadHandler there is no Lender. The +// type exposes no post-ctor observables (no LoadException-style state), so the +// honest assertion is "the DI ctor resolved a non-null instance". If a +// dependency registration breaks, Plugin's eager resolve throws before this +// step; the step pins that the singleton is reachable through the real +// container property, not via new(). +internal sealed class ChunkRendererCtorSmokeStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public ChunkRendererCtorSmokeStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - ChunkRenderer ctor smoke"; + + public SelfTestStepResult RunStep() + { + if (this.plugin.ChunkRenderer is null) + { + ImGui.Text("Plugin.ChunkRenderer is null"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/ColorEditorBufferStep.cs b/HellionChat/SelfTests/ColorEditorBufferStep.cs new file mode 100644 index 0000000..db9fdd8 --- /dev/null +++ b/HellionChat/SelfTests/ColorEditorBufferStep.cs @@ -0,0 +1,68 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Themes; + +namespace HellionChat.SelfTests; + +internal sealed class ColorEditorBufferStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public ColorEditorBufferStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - Color editor buffer"; + + public SelfTestStepResult RunStep() + { + var registry = _plugin.ThemeRegistry; + var originalActive = registry.Active; + var fired = 0; + Action handler = () => fired++; + + try + { + registry.OnEditingBufferChanged += handler; + registry.BeginEditing(originalActive); + + if (registry.EditingThemeBuffer is null) + { + ImGui.Text("EditingThemeBuffer should not be null after BeginEditing"); + return SelfTestStepResult.Fail; + } + + var mutatedColors = registry.EditingThemeBuffer.Colors with { Primary = 0xFF112233 }; + registry.UpdateEditingBuffer(mutatedColors); + + if (fired != 1) + { + ImGui.Text($"Expected OnEditingBufferChanged once, got {fired}"); + return SelfTestStepResult.Fail; + } + + registry.DiscardEditingBuffer(); + + if (registry.EditingThemeBuffer is not null) + { + ImGui.Text("EditingThemeBuffer should be null after Discard"); + return SelfTestStepResult.Fail; + } + + if (registry.Active != originalActive) + { + ImGui.Text("Active theme should be unchanged after Discard"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + finally + { + registry.OnEditingBufferChanged -= handler; + } + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/ConfigMigrationV23Step.cs b/HellionChat/SelfTests/ConfigMigrationV23Step.cs new file mode 100644 index 0000000..48a0f62 --- /dev/null +++ b/HellionChat/SelfTests/ConfigMigrationV23Step.cs @@ -0,0 +1,68 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// Pins the post-migration shape of the v23 config. By /xlperf time the schema +// gate has already stamped Config.Version = 23 and run the SidebarTabView→ +// TopTabs migration, so MainWindowLayoutMode must carry a valid value here. +// This probe never rewrites config; the actual migration (false → TopTabs) is +// load-time and verified by the prepared-config smoke in the plan. +internal sealed class ConfigMigrationV23Step : ISelfTestStep +{ + public ConfigMigrationV23Step(Plugin plugin) + { + _ = plugin; + } + + public string Name => "Hellion Chat - Config v23 migration"; + + public SelfTestStepResult RunStep() + { + if (Plugin.Config.Version != 23) + { + ImGui.Text($"Config.Version is {Plugin.Config.Version}, expected 23"); + return SelfTestStepResult.Fail; + } + + if (Plugin.Config.MaxParallelPopouts <= 0) + { + ImGui.Text( + $"Config.MaxParallelPopouts is {Plugin.Config.MaxParallelPopouts}, must be > 0" + ); + return SelfTestStepResult.Fail; + } + + if (Plugin.Config.SidebarAutoSwitchThresholdPx <= 0) + { + ImGui.Text( + $"Config.SidebarAutoSwitchThresholdPx is {Plugin.Config.SidebarAutoSwitchThresholdPx}, must be > 0" + ); + return SelfTestStepResult.Fail; + } + + if (!Enum.IsDefined(Plugin.Config.TellAutoOpenMode)) + { + ImGui.Text($"Config.TellAutoOpenMode {Plugin.Config.TellAutoOpenMode} is out of range"); + return SelfTestStepResult.Fail; + } + + if (!Enum.IsDefined(Plugin.Config.MainWindowLayoutMode)) + { + ImGui.Text( + $"Config.MainWindowLayoutMode {Plugin.Config.MainWindowLayoutMode} is out of range" + ); + return SelfTestStepResult.Fail; + } + + // Touch-tests: declaration proves the migration emitted these with + // defaults; reading them confirms the property is reachable. + _ = Plugin.Config.MainWindowOpen; + _ = Plugin.Config.SettingsWindowOpen; + _ = Plugin.Config.ScreenshotMode; + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/CurrentTabCouplingStep.cs b/HellionChat/SelfTests/CurrentTabCouplingStep.cs new file mode 100644 index 0000000..a6e3ddc --- /dev/null +++ b/HellionChat/SelfTests/CurrentTabCouplingStep.cs @@ -0,0 +1,121 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// F2: CurrentTab is coupled to MainWindow.ActiveTab (no longer the fixed index-0 +// Tabs lookup). Asserts ReferenceEquals between the two, with false-green +// defenses: (1) empty-config exercises the getter's fallback; (2) null ActiveTab +// opens the window so the Draw-seed sets it and retries via Waiting (bounded so a +// never-drawn window cannot hang a batch); (3) a victim tab at index 0 makes a +// regressed index-0 getter return the victim (!= ActiveTab) and fail. Also checks +// the ResetActiveTabIfRemoved reference no-op branch. +internal sealed class CurrentTabCouplingStep : ISelfTestStep +{ + private readonly Plugin _plugin; + private bool _forcedOpen; + private int _waitFrames; + + public CurrentTabCouplingStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - CurrentTab couples to active tab"; + + public SelfTestStepResult RunStep() + { + // Empty-config edge: actually exercise the getter's empty-fallback (it must + // return a fresh Tab, not null/throw) rather than an unconditional pass. + if (Plugin.Config.Tabs.Count == 0) + { + if (_plugin.CurrentTab is null) + { + ImGui.Text("Empty-config getter returned null instead of a fallback Tab."); + return SelfTestStepResult.Fail; + } + + ImGui.Text("No tabs configured; getter returns the empty-fallback Tab."); + return SelfTestStepResult.Pass; + } + + // /xlperf usually runs without the window drawn, so ActiveTab can be null + // on the first pass. Open the window so the Draw-seed sets it, retry next + // frame, and assert unconditionally once it is non-null. Bounded so a + // never-drawn window cannot hang a batch run. + if (_plugin.MainWindow.ActiveTab is null) + { + if (!_plugin.MainWindow.IsOpen) + { + _plugin.MainWindow.Toggle(); + _forcedOpen = true; + } + + if (++_waitFrames > 300) + { + RestoreWindow(); + ImGui.Text( + "MainWindow never drew a seed within 300 frames; coupling not asserted." + ); + return SelfTestStepResult.Pass; + } + + ImGui.Text("Opening window so the draw-seed can set ActiveTab; retrying..."); + return SelfTestStepResult.Waiting; + } + + try + { + // Insert a victim at index 0: a regressed index-0 getter would return + // THIS instead of ActiveTab, so ReferenceEquals would catch it. + var victim = new Tab { Name = "selftest-coupling-victim" }; + Plugin.Config.Tabs.Insert(0, victim); + try + { + if (!ReferenceEquals(_plugin.CurrentTab, _plugin.MainWindow.ActiveTab)) + { + ImGui.Text("CurrentTab is not the same reference as ActiveTab"); + return SelfTestStepResult.Fail; + } + if (ReferenceEquals(_plugin.CurrentTab, victim)) + { + ImGui.Text("CurrentTab returned the index-0 victim (getter still index-based)"); + return SelfTestStepResult.Fail; + } + + // Reference no-op: resetting against a tab that is NOT the active + // one must leave the active reference untouched. + var activeBefore = _plugin.MainWindow.ActiveTab; + _plugin.MainWindow.ResetActiveTabIfRemoved(victim); + if (!ReferenceEquals(_plugin.MainWindow.ActiveTab, activeBefore)) + { + ImGui.Text("ResetActiveTabIfRemoved changed the active tab on a non-match"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + finally + { + Plugin.Config.Tabs.Remove(victim); + } + } + finally + { + RestoreWindow(); + } + } + + private void RestoreWindow() + { + if (_forcedOpen && _plugin.MainWindow.IsOpen) + _plugin.MainWindow.Toggle(); + _forcedOpen = false; + } + + public void CleanUp() + { + RestoreWindow(); + _waitFrames = 0; + } +} diff --git a/HellionChat/SelfTests/CurrentTabGuidedStep.cs b/HellionChat/SelfTests/CurrentTabGuidedStep.cs new file mode 100644 index 0000000..dfb8b45 --- /dev/null +++ b/HellionChat/SelfTests/CurrentTabGuidedStep.cs @@ -0,0 +1,141 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.GameFunctions.Types; + +namespace HellionChat.SelfTests; + +// F2 (guided): interactive, fires NO synthetic probes. Shows the full measured +// state every frame so a result is observable, not a guess, and walks the user +// through the real switch-away-and-back flow. It verifies the PRIVACY-relevant +// effect, keyed on the tab type: +// - a NORMAL tab carrying a game-side tell must lose its RUNTIME target +// (CurrentChannel.TellTarget) on switch-away-and-back (the F1 strip), so a +// typed line can't /tell the old partner; +// - a BOUND auto-tell tab keeps its partner by design (leg1) — its binding is +// Tab.TellTarget and is deliberately untouched by the strip. +// The channel label is intentionally NOT asserted: a tell tab re-derives back to +// Tell after the strip (spec TR-7); only the target matters for privacy. +internal sealed class CurrentTabGuidedStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + // 0 = waiting for a tell; 1 = tell seen, waiting to switch AWAY; 2 = switched + // away, waiting to come BACK to the tracked tab. + private int _phase; + private Tab? _tellTab; + private bool _wasBound; + private string _seenPartner = ""; + + public CurrentTabGuidedStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - Tell target cleared on tab switch (guided)"; + + public SelfTestStepResult RunStep() + { + var active = _plugin.CurrentTab; + var cc = active.CurrentChannel; + var bound = active.TellTarget?.IsSet() == true; + var runtime = cc.TellTarget?.IsSet() == true; + + // Live diagnostics every frame — a result is never a guess. + ImGui.Text($"Active tab : {active.Name}"); + ImGui.Text($"Channel : {cc.Channel}"); + ImGui.Text($"Runtime target : {DescribeTarget(cc.TellTarget)}"); + ImGui.Text($"Tab-bound (leg1): {(bound ? $"yes -> {active.TellTarget!.Name}" : "no")}"); + if (_tellTab is not null) + ImGui.Text( + $"Tracking '{_tellTab.Name}' (bound: {_wasBound}, partner: {_seenPartner})" + ); + ImGui.Separator(); + + if (ImGui.Button("Skip##guided-tellflow")) + { + ImGui.Text("Skipped by user — not verified."); + return SelfTestStepResult.Pass; + } + + // Restart cleanly if the tracked tab is evicted mid-flow. + if (_tellTab is not null && !Plugin.Config.Tabs.Contains(_tellTab)) + { + ImGui.Text(">> Tracked tab was removed; restarting."); + Reset(); + } + + if (_phase == 0) + { + ImGui.Text(">> Step 1: get a tab into Tell — /tell from a normal tab (stay on it),"); + ImGui.Text(" or open an auto-tell tab. Watch the lines above update."); + if (cc.Channel == InputChannel.Tell && (runtime || bound)) + { + _tellTab = active; + _wasBound = bound; + _seenPartner = bound ? active.TellTarget!.Name : cc.TellTarget!.Name; + _phase = 1; + } + + return SelfTestStepResult.Waiting; + } + + if (_phase == 1) + { + ImGui.Text(">> Step 2: now click AWAY to a different tab."); + if (!ReferenceEquals(active, _tellTab)) + _phase = 2; + + return SelfTestStepResult.Waiting; + } + + // _phase == 2: switched away; wait to come BACK, then check the target. + ImGui.Text($">> Step 3: now click BACK onto '{_tellTab!.Name}'."); + if (!ReferenceEquals(active, _tellTab)) + return SelfTestStepResult.Waiting; + + if (_wasBound) + { + // leg1: the binding lives on Tab.TellTarget and must survive the strip. + if (_tellTab.TellTarget?.IsSet() == true) + { + ImGui.Text( + "PASS: bound auto-tell tab kept its partner (leg1 — the conversation stays)." + ); + return SelfTestStepResult.Pass; + } + + ImGui.Text( + $"FAIL: bound tab LOST partner '{_seenPartner}' — leg1 was wrongly stripped." + ); + return SelfTestStepResult.Fail; + } + + // non-bound: the stale RUNTIME target must be gone (the privacy strip). + if (_tellTab.CurrentChannel.TellTarget?.IsSet() != true) + { + ImGui.Text( + $"PASS: stale partner '{_seenPartner}' cleared — a typed line won't /tell them." + ); + return SelfTestStepResult.Pass; + } + + ImGui.Text( + "FAIL: stale runtime partner still bound after switch-away-and-back — privacy leak." + ); + return SelfTestStepResult.Fail; + } + + private static string DescribeTarget(TellTarget? t) => + t?.IsSet() == true ? $"{t.Name} (World {t.World})" : "none"; + + private void Reset() + { + _phase = 0; + _tellTab = null; + _wasBound = false; + _seenPartner = ""; + } + + public void CleanUp() => Reset(); +} diff --git a/HellionChat/SelfTests/DisclosureArmStep.cs b/HellionChat/SelfTests/DisclosureArmStep.cs new file mode 100644 index 0000000..6e2fb9c --- /dev/null +++ b/HellionChat/SelfTests/DisclosureArmStep.cs @@ -0,0 +1,101 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text; +using Dalamud.Plugin.SelfTest; +using HellionChat._Helpers; + +namespace HellionChat.SelfTests; + +// B2-3: proves the plugin-disclosure arm-and-hold wires the (otherwise verwaist) +// scanner into the REAL send entry InputBar.TrySend. Drives TrySend via the +// arm-test-hook with a PUA glyph in the buffer and NotifyPluginDisclosure on: +// the first send must ARM and HOLD (no send), so PendingMessage stays the probe +// string and the armed flag is set. Arm-case ONLY (seiteneffektfrei): a real +// send fires ChatBox.SendMessageUnsafe (a real in-game chat line), so the +// second-Enter-sends + ASCII-passthrough legs are in-game smoke only, never +// headless. Does NOT call PluginDisclosureScanner.ContainsPrivateUseGlyph in +// isolation (the false-green trap — it has no other production caller). +internal sealed class DisclosureArmStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public DisclosureArmStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - plugin disclosure arm"; + + public SelfTestStepResult RunStep() + { + var input = this.plugin.InputBar; + if (input is null) + { + ImGui.Text("Plugin.InputBar is null"); + return SelfTestStepResult.Fail; + } + + // The SymbolPicker inserts exactly these FFXIV Private-Use-Area glyphs; + // HighQuality is inside PluginDisclosureScanner's PUA range by + // construction (the scanner range IS the SeIconChar range). + var probe = $"test {SeIconChar.HighQuality.ToIconString()} msg"; + + var savedPending = input.PendingMessage; + var savedNotify = Plugin.Config.NotifyPluginDisclosure; + try + { + Plugin.Config.NotifyPluginDisclosure = true; + input.TestResetDisclosureForSelfTest(); + input.TestSetPendingMessageForSelfTest(probe); + + // Precondition guard: refuse to drive the real TrySend unless the + // toggle is on AND the scanner sees the probe glyph. If the scanner + // regressed, this bails with Fail WITHOUT ever calling TrySend, so a + // broken scanner can never leak a real chat line. (The remaining + // risk — TrySend not calling the scanner at all — is the wiring this + // step exists to catch and is covered by the documented residual-leak + // note + the mandatory mid-cycle smoke; see the Step 4.8 warning box.) + if ( + !Plugin.Config.NotifyPluginDisclosure + || !PluginDisclosureScanner.ContainsPrivateUseGlyph(input.PendingMessage) + ) + { + ImGui.Text( + "Disclosure precondition not met (toggle off or probe glyph not in the scanner's PUA range) — refusing to drive TrySend to avoid an unintended real send" + ); + return SelfTestStepResult.Fail; + } + + // First send with a PUA glyph + toggle on must ARM, not send. Pass a + // null Tab — the arm branch returns before any channel/send use. + var armed = input.TestTryArmDisclosureForSelfTest(null); + + if (!armed) + { + ImGui.Text( + "First send did not arm disclosure for a PUA-glyph buffer (scanner not wired into TrySend?)" + ); + return SelfTestStepResult.Fail; + } + + // Buffer must be HELD: TrySend clears _pendingMessage to empty only on + // a real send, so an unchanged probe proves nothing was transmitted. + if (input.PendingMessage != probe) + { + ImGui.Text( + $"Buffer not held on arm: PendingMessage = '{input.PendingMessage}', expected the unchanged probe (a cleared buffer means it actually sent)" + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + finally + { + input.TestResetDisclosureForSelfTest(); + input.TestSetPendingMessageForSelfTest(savedPending); + Plugin.Config.NotifyPluginDisclosure = savedNotify; + } + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/HideRestoreSelfTestStep.cs b/HellionChat/SelfTests/HideRestoreSelfTestStep.cs new file mode 100644 index 0000000..cb5ebf9 --- /dev/null +++ b/HellionChat/SelfTests/HideRestoreSelfTestStep.cs @@ -0,0 +1,69 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.Windows; + +namespace HellionChat.SelfTests; + +// P8 wiring: UserHide() suppresses DrawConditions; both ActivateChat() (Enter) and +// Toggle() (/hellion) restore it. Pure window-state — the focus side is left to smoke. +internal sealed class HideRestoreSelfTestStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public HideRestoreSelfTestStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - Hide + activate restore"; + + public SelfTestStepResult RunStep() + { + var window = _plugin.MainWindow; + if (window is null) + { + ImGui.Text("Plugin.MainWindow is null"); + return SelfTestStepResult.Fail; + } + + var savedOpen = window.IsOpen; + var result = Evaluate(window); + + // Never leave the window stuck hidden, even if an assertion failed. + window.ActivateChat(); + window.IsOpen = savedOpen; + return result; + } + + private static SelfTestStepResult Evaluate(MainWindow window) + { + window.UserHide(); + if (window.DrawConditions()) + { + ImGui.Text("UserHide did not suppress DrawConditions"); + return SelfTestStepResult.Fail; + } + + window.ActivateChat(); + if (!window.DrawConditions() || !window.IsOpen) + { + ImGui.Text( + $"ActivateChat failed: DrawConditions={window.DrawConditions()}, IsOpen={window.IsOpen}" + ); + return SelfTestStepResult.Fail; + } + + // /hellion (Toggle) must also clear a user-hide, not just flip IsOpen. + window.UserHide(); + window.Toggle(); + if (!window.DrawConditions()) + { + ImGui.Text("Toggle did not restore the window from a user-hide"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/HonorificHeaderRenderStep.cs b/HellionChat/SelfTests/HonorificHeaderRenderStep.cs new file mode 100644 index 0000000..1f10a32 --- /dev/null +++ b/HellionChat/SelfTests/HonorificHeaderRenderStep.cs @@ -0,0 +1,120 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Integrations; + +namespace HellionChat.SelfTests; + +// HonorificHeader has to render without crashing whether the Honorific +// plugin is reachable or not. This probe drives the component through +// one Draw call with the live HonorificService state. The fallback +// path (no IPC, no title) renders just the crown — the present-title +// path renders crown + bracketed title — both must survive without an +// exception. +internal sealed class HonorificHeaderRenderStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public HonorificHeaderRenderStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - HonorificHeader render"; + + private HonorificService? _svc; + private bool _prevAvailable; + private (uint Major, uint Minor)? _prevVersion; + private HonorificTitleData? _prevTitle; + private bool _prevToggle; + private bool _snapshotted; + + public SelfTestStepResult RunStep() + { + // HonorificHeader.Draw early-returns on !FontsReady (HonorificHeader.cs:40-44) + // and never reaches the gated title branch, which would make assert (a) a + // false FAIL during a font-atlas rebuild. Return Waiting BEFORE any + // snapshot/mutation so the runner re-polls cleanly and no seam state leaks + // (precedent: FoxBannerTextureSmokeStep). This is a pre-Set precondition + // gate, not a mid-test Waiting — the Set->Draw->Assert window stays synchronous. + if (!plugin.FontManager.FontsReady) + { + return SelfTestStepResult.Waiting; + } + + var header = plugin.MainWindow.GetHonorificHeaderForSelfTest(); + if (header is null) + { + ImGui.Text("MainWindow.HonorificHeader reference is null"); + return SelfTestStepResult.Fail; + } + + _svc = header.GetServiceForSelfTest(); + _prevAvailable = _svc.IsAvailable; + _prevVersion = _svc.DetectedApiVersion; + _prevTitle = _svc.CurrentTitle; + _prevToggle = Plugin.Config.ShowHonorificTitleInHeader; + _snapshotted = true; + + var valid = new HonorificTitleData("Champion", false, false, null, null, null, null, null); + var original = new HonorificTitleData( + "Champion", + false, + true, + null, + null, + null, + null, + null + ); + + // Draw at a deliberately wide 420px so the title never hits the truncation + // clamp — LastTitleRendered then reflects the GATE outcome, not the width. + try + { + // (a) available + valid title + toggle on -> title renders + Plugin.Config.ShowHonorificTitleInHeader = true; + _svc.TestOnly_SetState(true, (3, 1), valid); + header.Draw(420f); + if (!header.LastTitleRendered) + { + ImGui.Text("Gate failed: valid title did not render"); + return SelfTestStepResult.Fail; + } + + // (b) toggle off -> title suppressed (crown stays, untestable headless) + Plugin.Config.ShowHonorificTitleInHeader = false; + header.Draw(420f); + if (header.LastTitleRendered) + { + ImGui.Text("Gate failed: title rendered with toggle off"); + return SelfTestStepResult.Fail; + } + + // (c) IsOriginal title -> suppressed even with toggle on + Plugin.Config.ShowHonorificTitleInHeader = true; + _svc.TestOnly_SetState(true, (3, 1), original); + header.Draw(420f); + if (header.LastTitleRendered) + { + ImGui.Text("Gate failed: original title rendered"); + return SelfTestStepResult.Fail; + } + } + catch (Exception ex) + { + ImGui.Text($"HonorificHeader.Draw threw: {ex.GetType().Name}: {ex.Message}"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() + { + if (!_snapshotted || _svc is null) + return; + Plugin.Config.ShowHonorificTitleInHeader = _prevToggle; + _svc.TestOnly_SetState(_prevAvailable, _prevVersion, _prevTitle); + _snapshotted = false; + } +} diff --git a/HellionChat/SelfTests/HoverSheenAllocStep.cs b/HellionChat/SelfTests/HoverSheenAllocStep.cs new file mode 100644 index 0000000..12edffb --- /dev/null +++ b/HellionChat/SelfTests/HoverSheenAllocStep.cs @@ -0,0 +1,50 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; + +namespace HellionChat.SelfTests; + +// Master-spec scope note: the hover-sheen key dictionary must not grow +// frame-by-frame on a constant-key call site. This probe drives 100 +// hovered frames against three constant keys and asserts the dictionary +// only holds those three keys at the end — re-hover does not duplicate +// entries, and the un-hover branch clears the stale start timestamp. +internal sealed class HoverSheenAllocStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public HoverSheenAllocStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - HoverSheen dictionary footprint"; + + public SelfTestStepResult RunStep() + { + // Probe runs outside a regular draw frame, so the sheen path + // would normally not have a window draw-list. We pull the + // foreground draw-list directly — it accepts AddRectFilled + // even without an active window scope. + var dl = ImGui.GetForegroundDrawList(); + var theme = plugin.ThemeRegistry.Active; + var resolver = new TokenResolver(); + var accent = resolver.Resolve(Token.AccentPrimary, theme.Colors); + var min = new System.Numerics.Vector2(0, 0); + var max = new System.Numerics.Vector2(10, 10); + + string[] keys = ["selftest.row.a", "selftest.row.b", "selftest.row.c"]; + for (var frame = 0; frame < 100; frame++) + foreach (var key in keys) + dl.DrawHoverSheen(min, max, accent, key, hovered: true); + + // Un-hover sweep to verify the cleanup path drops the entries. + foreach (var key in keys) + dl.DrawHoverSheen(min, max, accent, key, hovered: false); + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/MainWindowFlagsStep.cs b/HellionChat/SelfTests/MainWindowFlagsStep.cs new file mode 100644 index 0000000..9462c39 --- /dev/null +++ b/HellionChat/SelfTests/MainWindowFlagsStep.cs @@ -0,0 +1,96 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.Windows; + +namespace HellionChat.SelfTests; + +// B1-2 window flags. Drives the REAL MainWindow.PreDraw and asserts it wired +// Window.Flags to ResolveFlags(CanMove, CanResize), then pins the pure +// fresh-base contract: false/false adds NoMove|NoResize, true/true clears them +// (the masterplan's "flags must rebuild from a fresh base, else NoMove sticks +// after toggling back" risk). NoScrollbar|NoScrollWithMouse always present. +// Non-test caller of ResolveFlags: MainWindow.PreDraw. +internal sealed class MainWindowFlagsStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public MainWindowFlagsStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - MainWindow flags"; + + public SelfTestStepResult RunStep() + { + var window = this.plugin.MainWindow; + if (window is null) + { + ImGui.Text("Plugin.MainWindow is null"); + return SelfTestStepResult.Fail; + } + + // Wiring proof: drive the real PreDraw and confirm Flags == the helper's + // value for the live config. No state mutation needed. + var savedFlags = window.Flags; + window.PreDraw(); + var expected = MainWindow.ResolveFlags( + Plugin.Config.CanMove, + Plugin.Config.CanResize, + Plugin.Config.ShowTitleBar + ); + if (window.Flags != expected) + { + ImGui.Text($"PreDraw set Flags {window.Flags}, expected ResolveFlags = {expected}"); + window.Flags = savedFlags; + return SelfTestStepResult.Fail; + } + + // Fresh-base contract: locked window carries NoMove|NoResize ... + var locked = MainWindow.ResolveFlags(false, false, true); + if ( + !locked.HasFlag(ImGuiWindowFlags.NoMove) + || !locked.HasFlag(ImGuiWindowFlags.NoResize) + || !locked.HasFlag(ImGuiWindowFlags.NoScrollbar) + ) + { + ImGui.Text( + $"ResolveFlags(false,false,true) = {locked}, missing NoMove/NoResize/NoScrollbar" + ); + window.Flags = savedFlags; + return SelfTestStepResult.Fail; + } + + // ... and re-enabling both CLEARS NoMove|NoResize (no accumulation). + var free = MainWindow.ResolveFlags(true, true, true); + if (free.HasFlag(ImGuiWindowFlags.NoMove) || free.HasFlag(ImGuiWindowFlags.NoResize)) + { + ImGui.Text( + $"ResolveFlags(true,true,true) = {free}, NoMove/NoResize stuck after re-enable" + ); + window.Flags = savedFlags; + return SelfTestStepResult.Fail; + } + + // P7 title-bar contract: ShowTitleBar=false adds NoTitleBar from the + // fresh base, true clears it (same no-accumulation guarantee). + var barHidden = MainWindow.ResolveFlags(true, true, false); + var barShown = MainWindow.ResolveFlags(true, true, true); + if ( + !barHidden.HasFlag(ImGuiWindowFlags.NoTitleBar) + || barShown.HasFlag(ImGuiWindowFlags.NoTitleBar) + ) + { + ImGui.Text( + $"NoTitleBar wiring wrong: hidden={barHidden} (want NoTitleBar), shown={barShown} (want none)" + ); + window.Flags = savedFlags; + return SelfTestStepResult.Fail; + } + + window.Flags = savedFlags; + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/MainWindowFocusOpacityStep.cs b/HellionChat/SelfTests/MainWindowFocusOpacityStep.cs new file mode 100644 index 0000000..0076e0a --- /dev/null +++ b/HellionChat/SelfTests/MainWindowFocusOpacityStep.cs @@ -0,0 +1,54 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// UI-12 focus opacity. Pins the pure ResolveBgAlpha contract (focused → +// WindowOpacity, unfocused → WindowOpacityInactive). The PreDraw wiring +// (BgAlpha = ResolveBgAlpha(IsFocused) behind the main-viewport/!docked guard) +// is NOT headless-deterministic — the guard may leave BgAlpha null when +// LastViewport is stale on a /xlperf frame — so the wiring is verified by the +// reviewer grep (ResolveBgAlpha has a non-test caller: MainWindow.PreDraw) and +// the visible transparency by in-game smoke, not by driving PreDraw here. +internal sealed class MainWindowFocusOpacityStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public MainWindowFocusOpacityStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - MainWindow focus opacity"; + + public SelfTestStepResult RunStep() + { + var window = this.plugin.MainWindow; + if (window is null) + { + ImGui.Text("Plugin.MainWindow is null"); + return SelfTestStepResult.Fail; + } + + // Contract: focused returns the focused opacity, unfocused the inactive one. + if (window.ResolveBgAlpha(true) != Plugin.Config.WindowOpacity) + { + ImGui.Text( + $"ResolveBgAlpha(true) = {window.ResolveBgAlpha(true)}, expected {Plugin.Config.WindowOpacity}" + ); + return SelfTestStepResult.Fail; + } + + if (window.ResolveBgAlpha(false) != Plugin.Config.WindowOpacityInactive) + { + ImGui.Text( + $"ResolveBgAlpha(false) = {window.ResolveBgAlpha(false)}, expected {Plugin.Config.WindowOpacityInactive}" + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/NotificationSoundSelectStep.cs b/HellionChat/SelfTests/NotificationSoundSelectStep.cs new file mode 100644 index 0000000..7009fdf --- /dev/null +++ b/HellionChat/SelfTests/NotificationSoundSelectStep.cs @@ -0,0 +1,106 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.Util; + +namespace HellionChat.SelfTests; + +// B3-3: notification-sound selection. Drives the pure SelectNotificationSound +// (the exact pick logic ProcessMessage runs per message) through its SelfTest +// wrapper with local synthetic tabs — Plugin.Config.Tabs is never touched, so +// no real tab gains messages or unread state. The audible preview button is +// smoke-only and deliberately not exercised here. +internal sealed class NotificationSoundSelectStep : ISelfTestStep +{ + public string Name => "Hellion Chat - Notification sound selection"; + + public SelfTestStepResult RunStep() + { + // Probe: a plain Say line, built the FakeMessage way (InputPreview / + // AutoTellTabsService pattern). Source 0 short-circuits the source + // filter in Message.Matches, so only the ChatType key decides a match. + var ss = new SeStringBuilder().AddText("probe").Build(); + var chunks = ChunkUtil.ToChunks(ss, ChunkSource.Content, ChatType.Say).ToList(); + var probe = Message.FakeMessage(chunks, new ChatCode(XivChatType.Say, 0, 0)); + + // The current tab wants a sound too — it must lose ONLY because it is + // current, so a broken is-active exclusion yields 1 instead of 7 here. + var currentTab = MakeSayTab(enableSound: true, soundId: 1); + var inactiveWanting = MakeSayTab(enableSound: true, soundId: 7); + + // (a) the inactive tab that wants a sound wins. + var picked = MessageManager.TestSelectNotificationSoundForSelfTest( + [currentTab, inactiveWanting], + currentTab, + probe, + playSounds: true + ); + if (picked != 7) + { + ImGui.Text($"Expected sound 7 from inactive tab, got {picked?.ToString() ?? "null"}"); + return SelfTestStepResult.Fail; + } + + // (b) first match wins: a later qualifying tab must not override. + var second = MakeSayTab(enableSound: true, soundId: 9); + picked = MessageManager.TestSelectNotificationSoundForSelfTest( + [currentTab, inactiveWanting, second], + currentTab, + probe, + playSounds: true + ); + if (picked != 7) + { + ImGui.Text($"First-match guard broken: expected 7, got {picked?.ToString() ?? "null"}"); + return SelfTestStepResult.Fail; + } + + // (c) the global sound master mutes everything. + picked = MessageManager.TestSelectNotificationSoundForSelfTest( + [currentTab, inactiveWanting], + currentTab, + probe, + playSounds: false + ); + if (picked is not null) + { + ImGui.Text($"PlaySounds=false must return null, got {picked}"); + return SelfTestStepResult.Fail; + } + + // (d) negative: a tab without the Say channel never matches the probe. + var nonMatching = new Tab { EnableNotificationSound = true, NotificationSoundId = 7 }; + picked = MessageManager.TestSelectNotificationSoundForSelfTest( + [currentTab, nonMatching], + currentTab, + probe, + playSounds: true + ); + if (picked is not null) + { + ImGui.Text($"Non-matching tab must not pick a sound, got {picked}"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + // Local synthetic tab matching Say, the way TabsUtil presets build their + // channel maps. Non-temp and without TellTarget, so Tab.Matches stays on + // the pure channel path instead of routing through MatchesSender. + private static Tab MakeSayTab(bool enableSound, uint soundId) => + new() + { + Name = "selftest-sound", + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + EnableNotificationSound = enableSound, + NotificationSoundId = soundId, + }; + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/OnOpenMainUiRoutesMainWindowStep.cs b/HellionChat/SelfTests/OnOpenMainUiRoutesMainWindowStep.cs new file mode 100644 index 0000000..d17d46a --- /dev/null +++ b/HellionChat/SelfTests/OnOpenMainUiRoutesMainWindowStep.cs @@ -0,0 +1,45 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +internal sealed class OnOpenMainUiRoutesMainWindowStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public OnOpenMainUiRoutesMainWindowStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - OpenMainUi routes to MainWindow"; + + public SelfTestStepResult RunStep() + { + var settingsBefore = _plugin.SettingsWindow.IsOpen; + var mainBefore = _plugin.MainWindow.IsOpen; + + _plugin.MainWindow.Toggle(); + + var mainAfter = _plugin.MainWindow.IsOpen; + var settingsAfter = _plugin.SettingsWindow.IsOpen; + + // Restore original state. + _plugin.MainWindow.Toggle(); + + if (mainAfter == mainBefore) + { + ImGui.Text("MainWindow did not toggle"); + return SelfTestStepResult.Fail; + } + if (settingsAfter != settingsBefore) + { + ImGui.Text("SettingsWindow state changed unexpectedly"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/PayloadHandlerCtorSmokeStep.cs b/HellionChat/SelfTests/PayloadHandlerCtorSmokeStep.cs new file mode 100644 index 0000000..b0af9de --- /dev/null +++ b/HellionChat/SelfTests/PayloadHandlerCtorSmokeStep.cs @@ -0,0 +1,69 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// Drives the per-frame Lender path the same way MainWindow.Draw +// and InputPreview do (Borrow() + ResetCounter()), NOT the eager singleton. +// PayloadHandler is registered twice (PluginHostFactory.cs:253/254): an eager +// singleton for the init HostedServices, and a Lender factory-lambda for +// per-frame isolation. MS.DI resolves factory lambdas lazily and does not +// detect cycles through them, so a Borrow() that throws is the only automated +// signal of a broken lazy ctor before the first real frame renders. A +// singleton-only smoke would resolve the eager instance and mask exactly that +// failure. Resolve through the container/Lender, never new(). +internal sealed class PayloadHandlerCtorSmokeStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public PayloadHandlerCtorSmokeStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - PayloadHandler ctor smoke"; + + public SelfTestStepResult RunStep() + { + var lender = this.plugin.PayloadHandlerLender; + if (lender is null) + { + ImGui.Text("Plugin.PayloadHandlerLender is null"); + return SelfTestStepResult.Fail; + } + + // Borrow() runs MakePayloadHandler's factory lambda on first use; a + // throw or null here means a broken lazy ctor. This is the real + // per-frame construction path, not the eager singleton. + var borrowed = lender.Borrow(); + + // Keep the probe idempotent and avoid perturbing the frame path: + // MainWindow.Draw resets this same shared Lender every frame, so + // resetting here leaves a closed-MainWindow /xlperf run clean too. + lender.ResetCounter(); + + if (borrowed is null) + { + ImGui.Text("Lender.Borrow() returned null"); + return SelfTestStepResult.Fail; + } + + // Second construction path: the eager singleton the init HostedServices + // consume (PluginHostFactory.cs:253, :356). Assert it resolved too. + if (this.plugin.PayloadHandler is null) + { + ImGui.Text("Plugin.PayloadHandler (singleton) is null"); + return SelfTestStepResult.Fail; + } + + // NOTE: we deliberately do NOT assert HandleTooltips == false / + // HoveredItem == 0u. MainWindow and InputPreview share this Lender, so a + // warm pool can hand back a reused instance whose hover state was set by + // a prior frame. The honest ctor-smoke assertion is "constructs through + // the real lazy path and is reachable" — a non-default warm value does + // not contradict that. + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/PerformanceBaselineStep.cs b/HellionChat/SelfTests/PerformanceBaselineStep.cs new file mode 100644 index 0000000..3845b21 --- /dev/null +++ b/HellionChat/SelfTests/PerformanceBaselineStep.cs @@ -0,0 +1,45 @@ +using System.Diagnostics; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// Optional metric capture. Walks one frame's ImGui IO counters and +// prints a single JSON block so the cycle-notes author can copy/paste +// the snapshot without standing up a separate profiling harness. +// Investigations themselves are deferred to the polish cycle — this +// step only records, it never fails on threshold. +internal sealed class PerformanceBaselineStep : ISelfTestStep +{ + public PerformanceBaselineStep(Plugin plugin) + { + _ = plugin; + } + + public string Name => "Hellion Chat - Performance baseline capture"; + + public SelfTestStepResult RunStep() + { + var io = ImGui.GetIO(); + var stopwatch = Stopwatch.StartNew(); + // No actual probe — we just sample the counters that ImGui keeps + // updated each frame. Stopwatch is started so the JSON line + // includes a non-zero wall-time figure even when ImGui has not + // accumulated frame stats yet. + stopwatch.Stop(); + + ImGui.Text( + "{ " + + $"\"renderVertices\": {io.MetricsRenderVertices}, " + + $"\"renderIndices\": {io.MetricsRenderIndices}, " + + $"\"renderWindows\": {io.MetricsRenderWindows}, " + + $"\"activeWindows\": {io.MetricsActiveWindows}, " + + $"\"deltaTimeMs\": {io.DeltaTime * 1000f:F2}, " + + $"\"sampleWallTimeMs\": {stopwatch.Elapsed.TotalMilliseconds:F2}" + + " }" + ); + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/QuickPickerSelfTestStep.cs b/HellionChat/SelfTests/QuickPickerSelfTestStep.cs index ec0e537..2564f26 100644 --- a/HellionChat/SelfTests/QuickPickerSelfTestStep.cs +++ b/HellionChat/SelfTests/QuickPickerSelfTestStep.cs @@ -1,59 +1,40 @@ +using System.Linq; using Dalamud.Bindings.ImGui; using Dalamud.Plugin.SelfTest; using HellionChat.Resources; namespace HellionChat.SelfTests; -// Verifies the v1.5.4 PM-2 quick-picker plumbing without rendering: -// resource strings resolve, the theme registry yields the expected -// minimum built-in count, and Config.Tabs is populated. +// Guards the header quick-picker's data contract: its three section/tooltip +// strings must resolve and there must be at least one theme to switch to. The +// render path itself (FontAwesome push) can't run headless, so this checks the +// data the popup depends on, not the draw. internal sealed class QuickPickerSelfTestStep : ISelfTestStep { - private readonly Plugin plugin; + private readonly Plugin _plugin; public QuickPickerSelfTestStep(Plugin plugin) { - this.plugin = plugin; + _plugin = plugin; } - public string Name => "Hellion Chat - Quick picker plumbing"; + public string Name => "Hellion Chat - Theme quick-picker contract"; public SelfTestStepResult RunStep() { - if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Tooltip)) + if ( + string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Tooltip) + || string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Themes_Header) + || string.IsNullOrEmpty(HellionStrings.Settings_QuickPicker_Tabs_Header) + ) { - ImGui.Text("Settings_QuickPicker_Tooltip is empty in the active locale."); - return SelfTestStepResult.Fail; - } - if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Themes_Header)) - { - ImGui.Text("Settings_QuickPicker_Themes_Header is empty in the active locale."); - return SelfTestStepResult.Fail; - } - if (string.IsNullOrWhiteSpace(HellionStrings.Settings_QuickPicker_Tabs_Header)) - { - ImGui.Text("Settings_QuickPicker_Tabs_Header is empty in the active locale."); + ImGui.Text("Quick-picker strings did not resolve."); return SelfTestStepResult.Fail; } - var registry = this.plugin.ThemeRegistry; - if (registry is null) + if (!_plugin.ThemeRegistry.BuiltinSlugs.Any()) { - ImGui.Text("ThemeRegistry not resolved."); - return SelfTestStepResult.Fail; - } - - var builtIns = registry.AllBuiltIns().ToList(); - if (builtIns.Count < 10) - { - ImGui.Text($"Expected at least 10 built-in themes, found {builtIns.Count}."); - return SelfTestStepResult.Fail; - } - - var tabs = Plugin.Config.Tabs; - if (tabs is null || tabs.Count == 0) - { - ImGui.Text("Config.Tabs is empty."); + ImGui.Text("No built-in themes available for the quick-picker."); return SelfTestStepResult.Fail; } diff --git a/HellionChat/SelfTests/README.md b/HellionChat/SelfTests/README.md new file mode 100644 index 0000000..6f06e4f --- /dev/null +++ b/HellionChat/SelfTests/README.md @@ -0,0 +1,51 @@ +# HellionChat SelfTest Standard + +These steps run in-game via `/xlperf`. They are HellionChat's real test layer: +Dalamud-coupled classes cannot be instantiated in an xUnit AppDomain, so the +honest verification path is the running plugin, not a headless harness. + +## The render-path rule (binding for every step) + +A SelfTest exists to catch a broken **runtime** path. To do that it MUST: + +1. **ENTRY = the real runtime entry the game calls** per frame or on the real + action — `HonorificHeader.Draw`, `ChunkRenderer.DrawChunks`, + `InputBar.TrySend`, `Sidebar.Draw`, `MessageList.Draw`, + `Lender.Borrow()`. NEVER a helper only the test calls. +2. **ASSERT observable state produced _through_ that entry** — a rendered or + suppressed slot, a set flag, a held vs. sent message. Do NOT re-implement the + helper's logic inside the test and assert against your own copy. +3. **Wire first.** Where the real path does not yet call the correct helper, + wiring it is part of the restoration work; the SelfTest verifies only after. + +## Reviewer trick (run before trusting any step) + +For every helper a step calls: + +```bash +grep -rn '' HellionChat/ | grep -v SelfTests | grep -v Tests +``` + +Zero non-test callers = false-green suspect. The step is passing on dead code. + +## The hard gate + +Green steps + clean build + clean csharpier are NOT sufficient. In-game smoke +(Linux/Wine, via `/xlperf`) is the true gate. Where headless cannot honestly +verify (scroll state, real send, atlas rebuild, warm object pools), mark the +step explicitly as smoke-only instead of faking a headless pass. + +## Anti-pattern of record + +`HonorificService.ShouldRenderSlot` once had zero production callers and was +green only because the test called it directly — a test passing on a path the +game never runs. v1.8.7 retired it: the gate is now wired into the real +`HonorificHeader.Draw` and asserted through it via +`HonorificHeader.LastTitleRendered` (see `HonorificHeaderRenderStep`). Kept here +as the canonical example of the failure this standard prevents. + +## Step classification + +The current real-path / helper-only / mixed classification of every registered +step (with false-green suspects flagged) lives in the Obsidian vault: +`Projekte/FFXIV/Hellion Chat/Audits/HellionChat SelfTest-Klassifikation 2026-05-29.md`. diff --git a/HellionChat/SelfTests/ScrollSnapDecisionStep.cs b/HellionChat/SelfTests/ScrollSnapDecisionStep.cs new file mode 100644 index 0000000..54c3ce5 --- /dev/null +++ b/HellionChat/SelfTests/ScrollSnapDecisionStep.cs @@ -0,0 +1,59 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// B3-5: only the snap decision is headless-testable. Scroll detection + bar + +// hit-test are smoke-only (the scroll child exists only in-game; GetScrollY is +// garbage headless). Drives ResolveSnapToBottom via the SelfTest accessor and +// asserts the OR + the request reset invariant. +// Uses the mandatory RequestScrollToBottomForSelfTest() setter (added in Step 1) +// to flip _scrollToBottomRequested without a real click — REQUIRED for the reset +// invariant assert; without it only the OR branch is testable. +internal sealed class ScrollSnapDecisionStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public ScrollSnapDecisionStep(Plugin plugin) => this.plugin = plugin; + + public string Name => "Hellion Chat - Scroll snap decision"; + + public SelfTestStepResult RunStep() + { + var messages = plugin.MainWindow.GetMessageListForSelfTest(); + if (messages is null) + { + ImGui.Text("MessageList null"); + return SelfTestStepResult.Fail; + } + + // Start-state hygiene: a real click this frame could leave a pending + // request behind. Drain it so the asserts below are order-independent. + // Acceptable side effect: the drained click is swallowed and its snap + // never happens — losing one click mid-selftest is irrelevant. + messages.ResolveSnapToBottom(false); + + if (!messages.ResolveSnapToBottom(true)) + { + ImGui.Text("pinnedToBottom=true must snap"); + return SelfTestStepResult.Fail; + } + + messages.RequestScrollToBottomForSelfTest(); + if (!messages.ResolveSnapToBottom(false)) + { + ImGui.Text("pending request must snap even when not pinned"); + return SelfTestStepResult.Fail; + } + + if (messages.ResolveSnapToBottom(false)) + { + ImGui.Text("request must be consumed by one snap (reset invariant)"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/SenderNameReformatStep.cs b/HellionChat/SelfTests/SenderNameReformatStep.cs new file mode 100644 index 0000000..2e7c063 --- /dev/null +++ b/HellionChat/SelfTests/SenderNameReformatStep.cs @@ -0,0 +1,81 @@ +using System.Collections.Generic; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// B2-1/B2-2: proves the WorldSuffixMode/NameFormMode reformat reaches the REAL +// render entry. Drives ChunkRenderer.DrawChunks (a SelfTests/README-sanctioned +// real entry that wires SenderNameDisplay.ForDisplay at ChunkRenderer.cs:54) +// with a synthetic ChunkSource.Sender chunk carrying a PlayerPayload, at a +// non-neutral NameFormMode, and reads the LastRenderedSenderText observability +// the real draw produced. NameFormMode.Initials + WorldSuffixMode.Never is +// world-independent ("Test Tester" -> "T. T."), so the assertion is +// deterministic without a live world lookup. The MessageList routing (its row +// methods pass message.Sender to DrawChunks) is gated by the reviewer-grep +// (Step 2.6) + in-game smoke, since the visible sender change needs real chat + +// the world sheet. Does NOT call SenderNameFormatter/ForDisplay in isolation +// (the false-green trap — both are green today on a path the message list never +// takes for the sender). +internal sealed class SenderNameReformatStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public SenderNameReformatStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - sender name reformat"; + + public SelfTestStepResult RunStep() + { + var renderer = this.plugin.ChunkRenderer; + if (renderer is null) + { + ImGui.Text("Plugin.ChunkRenderer is null"); + return SelfTestStepResult.Fail; + } + + var savedForm = Plugin.Config.NameFormMode; + var savedSuffix = Plugin.Config.WorldSuffixMode; + var savedScreenshot = Plugin.Config.ScreenshotMode; + try + { + // Initials (non-neutral) so ForDisplay reformats; Never + screenshot + // off so the result is world-independent and the reformat is not + // skipped. + Plugin.Config.NameFormMode = NameFormMode.Initials; + Plugin.Config.WorldSuffixMode = WorldSuffixMode.Never; + Plugin.Config.ScreenshotMode = false; + + // ForDisplay formats payload.PlayerName, not the chunk text. + var payload = new PlayerPayload("Test Tester", 1u); + var senderChunks = new List + { + new TextChunk(ChunkSource.Sender, payload, "Test Tester"), + }; + + renderer.DrawChunks(senderChunks); + + if (renderer.LastRenderedSenderText != "T. T.") + { + ImGui.Text( + $"LastRenderedSenderText = '{renderer.LastRenderedSenderText}', expected 'T. T.' (Initials reformat through the real render path)" + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + finally + { + Plugin.Config.NameFormMode = savedForm; + Plugin.Config.WorldSuffixMode = savedSuffix; + Plugin.Config.ScreenshotMode = savedScreenshot; + } + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/SettingsWindowOpenStep.cs b/HellionChat/SelfTests/SettingsWindowOpenStep.cs new file mode 100644 index 0000000..628dbd2 --- /dev/null +++ b/HellionChat/SelfTests/SettingsWindowOpenStep.cs @@ -0,0 +1,37 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +internal sealed class SettingsWindowOpenStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public SettingsWindowOpenStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - Settings window toggles via direct call"; + + public SelfTestStepResult RunStep() + { + var initial = _plugin.SettingsWindow.IsOpen; + _plugin.SettingsWindow.Toggle(); + var afterFirst = _plugin.SettingsWindow.IsOpen; + _plugin.SettingsWindow.Toggle(); + var afterSecond = _plugin.SettingsWindow.IsOpen; + + if (afterFirst == initial || afterSecond != initial) + { + ImGui.Text( + $"Toggle did not flip state: initial={initial} after1={afterFirst} after2={afterSecond}" + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/SidebarGreetedGlyphStep.cs b/HellionChat/SelfTests/SidebarGreetedGlyphStep.cs new file mode 100644 index 0000000..3687ee6 --- /dev/null +++ b/HellionChat/SelfTests/SidebarGreetedGlyphStep.cs @@ -0,0 +1,103 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.GameFunctions.Types; + +namespace HellionChat.SelfTests; + +// B3-2: greeted glyph renders only for temp tabs when the toggle is on. Drives +// the REAL Sidebar.Draw (render precedent: HonorificHeaderRenderStep, the only +// real .Draw in this pool — NOT SidebarModeAutoSwitchStep which only calls +// IsExpanded/GetWidth) inside the /xlperf window frame and reads the render +// observability counter, then drives the real toggle hook both ways. Injects +// a temp tab and restores config in finally. +internal sealed class SidebarGreetedGlyphStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public SidebarGreetedGlyphStep(Plugin plugin) => this.plugin = plugin; + + public string Name => "Hellion Chat - Sidebar greeted glyph"; + + public SelfTestStepResult RunStep() + { + var sidebar = plugin.MainWindow.GetSidebarForSelfTest(); + if (sidebar is null) + { + ImGui.Text("Sidebar null"); + return SelfTestStepResult.Fail; + } + + var savedFlag = Plugin.Config.AutoTellTabsShowGreetedToggle; + var savedSidebarWidth = Plugin.Config.SidebarWidth; + + // Mirror of AutoTellTabsService.BuildTempTab (the real builder is + // private); only the sheet-based tab name is replaced with a literal. + var injected = new Tab + { + Name = "Greeted Probe@SelfTest", + IsTempTab = true, + AllSenderMessages = true, + TellTarget = new TellTarget("Greeted Probe", 0, 0, TellReason.Direct), + Channel = InputChannel.Tell, + DisplayTimestamp = true, + UnreadMode = UnreadMode.Unseen, + HideWhenInactive = false, + SelectedChannels = new Dictionary + { + [ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All), + [ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + Plugin.Config.Tabs.Add(injected); + Tab? active = null; + var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded + try + { + Plugin.Config.AutoTellTabsShowGreetedToggle = true; + // Default SidebarWidth (44px) has no room for the third hit area; + // pin a wide value so the glyph branch is reachable, restore after. + Plugin.Config.SidebarWidth = 220; + sidebar.Draw(width, Plugin.Config.Tabs, ref active); + if (sidebar.LastRenderedGreetedGlyphCount == 0) + { + ImGui.Text("No greeted glyph drawn with flag ON"); + return SelfTestStepResult.Fail; + } + + Plugin.Config.AutoTellTabsShowGreetedToggle = false; + sidebar.Draw(width, Plugin.Config.Tabs, ref active); + if (sidebar.LastRenderedGreetedGlyphCount != 0) + { + ImGui.Text("Greeted glyph drawn with flag OFF"); + return SelfTestStepResult.Fail; + } + + // Drive the same hook DrawRow's click handler uses (the real toggle + // path, not a direct MarkGreeted call) and assert the flip both ways. + sidebar.ToggleGreetedForSelfTest(injected); + if (!plugin.AutoTellTabsService.IsGreeted(injected)) + { + ImGui.Text("Toggle did not mark the tab greeted"); + return SelfTestStepResult.Fail; + } + + sidebar.ToggleGreetedForSelfTest(injected); + if (plugin.AutoTellTabsService.IsGreeted(injected)) + { + ImGui.Text("Toggle did not unmark the tab greeted"); + return SelfTestStepResult.Fail; + } + } + finally + { + Plugin.Config.Tabs.Remove(injected); + Plugin.Config.AutoTellTabsShowGreetedToggle = savedFlag; + Plugin.Config.SidebarWidth = savedSidebarWidth; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/SidebarModeAutoSwitchStep.cs b/HellionChat/SelfTests/SidebarModeAutoSwitchStep.cs new file mode 100644 index 0000000..cb9e41a --- /dev/null +++ b/HellionChat/SelfTests/SidebarModeAutoSwitchStep.cs @@ -0,0 +1,107 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.Components; + +namespace HellionChat.SelfTests; + +// Width-threshold guard. Sidebar must report Icon-only at any width +// below Config.SidebarAutoSwitchThresholdPx and Expanded once that +// threshold is crossed. The probe also pins the exact-threshold case +// because the contract uses >= (the threshold itself is Expanded). +internal sealed class SidebarModeAutoSwitchStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public SidebarModeAutoSwitchStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - Sidebar auto-switch threshold"; + + public SelfTestStepResult RunStep() + { + var sidebar = plugin.MainWindow.GetSidebarForSelfTest(); + if (sidebar is null) + { + ImGui.Text("MainWindow.Sidebar reference is null"); + return SelfTestStepResult.Fail; + } + + var threshold = (float)Plugin.Config.SidebarAutoSwitchThresholdPx; + + if (sidebar.IsExpanded(threshold - 1f)) + { + ImGui.Text($"Sidebar reported Expanded below threshold ({threshold - 1f}px)"); + return SelfTestStepResult.Fail; + } + + if (!sidebar.IsExpanded(threshold)) + { + ImGui.Text($"Sidebar should report Expanded at the threshold ({threshold}px)"); + return SelfTestStepResult.Fail; + } + + if (!sidebar.IsExpanded(threshold + 100f)) + { + ImGui.Text($"Sidebar should report Expanded above threshold ({threshold + 100f}px)"); + return SelfTestStepResult.Fail; + } + + var iconWidth = sidebar.GetWidth(threshold - 1f); + var expandedWidth = sidebar.GetWidth(threshold + 100f); + if (iconWidth >= expandedWidth) + { + ImGui.Text( + $"Icon-only width ({iconWidth}) should be smaller than Expanded width ({expandedWidth})" + ); + return SelfTestStepResult.Fail; + } + + // B1-3a: the expanded width must come from Config.SidebarWidth, not the + // old fixed 150 constant. Drive the REAL GetWidth (the single source + // Sidebar.Draw consumes) with concrete values and assert the OBSERVED + // effect — in-range passthrough plus clamping — instead of mirroring the + // Math.Clamp logic (SelfTests/README.md forbids re-implementing helper + // logic in the test). Restore the config in finally so the live render + // path is untouched. + var savedSidebarWidth = Plugin.Config.SidebarWidth; + try + { + Plugin.Config.SidebarWidth = 220; + if (sidebar.GetWidth(threshold + 100f) != 220f) + { + ImGui.Text( + $"GetWidth expanded = {sidebar.GetWidth(threshold + 100f)}, expected in-range Config.SidebarWidth 220" + ); + return SelfTestStepResult.Fail; + } + + Plugin.Config.SidebarWidth = 9999; + if (sidebar.GetWidth(threshold + 100f) != Sidebar.MaxSidebarWidth) + { + ImGui.Text( + $"GetWidth expanded = {sidebar.GetWidth(threshold + 100f)}, expected clamp to MaxSidebarWidth {Sidebar.MaxSidebarWidth}" + ); + return SelfTestStepResult.Fail; + } + + Plugin.Config.SidebarWidth = 1; + if (sidebar.GetWidth(threshold + 100f) != Sidebar.MinSidebarWidth) + { + ImGui.Text( + $"GetWidth expanded = {sidebar.GetWidth(threshold + 100f)}, expected clamp to MinSidebarWidth {Sidebar.MinSidebarWidth}" + ); + return SelfTestStepResult.Fail; + } + } + finally + { + Plugin.Config.SidebarWidth = savedSidebarWidth; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/SidebarSectionHeaderStep.cs b/HellionChat/SelfTests/SidebarSectionHeaderStep.cs new file mode 100644 index 0000000..dbaedd3 --- /dev/null +++ b/HellionChat/SelfTests/SidebarSectionHeaderStep.cs @@ -0,0 +1,118 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.GameFunctions.Types; + +namespace HellionChat.SelfTests; + +// B3-4: section headers render once per non-empty temp-tab pool, and compact +// mode suppresses the header text (separators stay). Drives the REAL +// Sidebar.Draw inside the /xlperf window frame (same render precedent as +// SidebarGreetedGlyphStep) and reads the render observability counter. +// Injects a mixed tab set (persistent + unpinned temp + pinned temp) and +// restores config in finally. +internal sealed class SidebarSectionHeaderStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public SidebarSectionHeaderStep(Plugin plugin) => this.plugin = plugin; + + public string Name => "Hellion Chat - Sidebar section header"; + + public SelfTestStepResult RunStep() + { + var sidebar = plugin.MainWindow.GetSidebarForSelfTest(); + if (sidebar is null) + { + ImGui.Text("Sidebar null"); + return SelfTestStepResult.Fail; + } + + var savedCompact = Plugin.Config.AutoTellTabsCompactDisplay; + var savedSidebarWidth = Plugin.Config.SidebarWidth; + + // Both headers need a populated pool behind them. Persistent tabs + // normally already exist — inject a probe only when the live config + // has none, so the section order has a real first section. + var injected = new List(); + if (Plugin.Config.Tabs.All(t => t.IsTempTab)) + { + injected.Add( + new Tab + { + Name = "Persistent Probe@SelfTest", + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + } + ); + } + injected.Add(BuildTempProbe("Tell Probe@SelfTest", pinned: false)); + injected.Add(BuildTempProbe("Pinned Probe@SelfTest", pinned: true)); + foreach (var tab in injected) + Plugin.Config.Tabs.Add(tab); + + Tab? active = null; + var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded + try + { + // Headers are not width-gated, but the pinned width keeps the step + // uniform with SidebarGreetedGlyphStep (expanded rows, no min-drag + // row drops while the probes render). + Plugin.Config.SidebarWidth = 220; + + Plugin.Config.AutoTellTabsCompactDisplay = false; + sidebar.Draw(width, Plugin.Config.Tabs, ref active); + if (sidebar.LastDrawnSectionHeaderCount != 2) + { + ImGui.Text( + $"Expected 2 section headers with compact OFF, got {sidebar.LastDrawnSectionHeaderCount}" + ); + return SelfTestStepResult.Fail; + } + + Plugin.Config.AutoTellTabsCompactDisplay = true; + sidebar.Draw(width, Plugin.Config.Tabs, ref active); + if (sidebar.LastDrawnSectionHeaderCount != 0) + { + ImGui.Text( + $"Compact ON must suppress header text, got {sidebar.LastDrawnSectionHeaderCount}" + ); + return SelfTestStepResult.Fail; + } + } + finally + { + foreach (var tab in injected) + Plugin.Config.Tabs.Remove(tab); + Plugin.Config.AutoTellTabsCompactDisplay = savedCompact; + Plugin.Config.SidebarWidth = savedSidebarWidth; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } + + // Mirror of AutoTellTabsService.BuildTempTab (the real builder is + // private); only the sheet-based tab name is replaced with a literal. + private static Tab BuildTempProbe(string name, bool pinned) => + new() + { + Name = name, + IsTempTab = true, + IsPinned = pinned, + AllSenderMessages = true, + TellTarget = new TellTarget(name, 0, 0, TellReason.Direct), + Channel = InputChannel.Tell, + DisplayTimestamp = true, + UnreadMode = UnreadMode.Unseen, + HideWhenInactive = false, + SelectedChannels = new Dictionary + { + [ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All), + [ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; +} diff --git a/HellionChat/SelfTests/SidebarUnreadDotStep.cs b/HellionChat/SelfTests/SidebarUnreadDotStep.cs new file mode 100644 index 0000000..fc8489d --- /dev/null +++ b/HellionChat/SelfTests/SidebarUnreadDotStep.cs @@ -0,0 +1,77 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; + +namespace HellionChat.SelfTests; + +// F3: the unread dot the v1.8.x sidebar rebuild dropped. Drives the REAL +// Sidebar.Draw (render precedent: SidebarGreetedGlyphStep) with a probe tab that +// is inactive and carries Unread>0, then reads the render-observability counter +// so a regressed/absent dot fails. Asserts: dot drawn for an inactive Unseen tab; +// NOT drawn for UnreadMode.None. Uses a local one-tab list so the count is +// unambiguous; restores SidebarWidth in finally. +internal sealed class SidebarUnreadDotStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public SidebarUnreadDotStep(Plugin plugin) => _plugin = plugin; + + public string Name => "Hellion Chat - Sidebar unread dot"; + + public SelfTestStepResult RunStep() + { + var sidebar = _plugin.MainWindow.GetSidebarForSelfTest(); + if (sidebar is null) + { + ImGui.Text("Sidebar null"); + return SelfTestStepResult.Fail; + } + + var probe = new Tab + { + Name = "Unread Probe@SelfTest", + UnreadMode = UnreadMode.Unseen, + Unread = 3, + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + var list = new List { probe }; + Tab? active = null; // probe is NOT the active tab + var width = (float)Plugin.Config.SidebarAutoSwitchThresholdPx + 100f; // expanded + var savedWidth = Plugin.Config.SidebarWidth; + try + { + Plugin.Config.SidebarWidth = 220; + + // (a) an inactive Unseen tab with Unread>0 draws exactly one dot + // (the one-tab list makes the expected count unambiguous). + sidebar.Draw(width, list, ref active); + if (sidebar.LastRenderedUnreadDotCount != 1) + { + ImGui.Text( + $"Expected exactly 1 unread dot, got {sidebar.LastRenderedUnreadDotCount}" + ); + return SelfTestStepResult.Fail; + } + + // (b) UnreadMode.None opts the tab out — no dot. + probe.UnreadMode = UnreadMode.None; + sidebar.Draw(width, list, ref active); + if (sidebar.LastRenderedUnreadDotCount != 0) + { + ImGui.Text("Unread dot drawn for an UnreadMode.None tab"); + return SelfTestStepResult.Fail; + } + } + finally + { + Plugin.Config.SidebarWidth = savedWidth; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/TabRenamePersistStep.cs b/HellionChat/SelfTests/TabRenamePersistStep.cs new file mode 100644 index 0000000..376bf82 --- /dev/null +++ b/HellionChat/SelfTests/TabRenamePersistStep.cs @@ -0,0 +1,59 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Ui.Components; + +namespace HellionChat.SelfTests; + +// B3-1: rename must persist. Drives the real ApplyTabRename (the InputText +// callback path), then SaveConfig + reload from disk and asserts the new name +// survived — a fresh-from-config tab, not the same reference (a reference check +// would pass on a dead roundtrip). Uses a persistent (non-temp) tab: unpinned +// temp tabs are stripped on save (ShouldStripOnSave) and would not survive. +internal sealed class TabRenamePersistStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public TabRenamePersistStep(Plugin plugin) => this.plugin = plugin; + + public string Name => "Hellion Chat - Tab rename persists"; + + public SelfTestStepResult RunStep() + { + var tab = Plugin.Config.Tabs.FirstOrDefault(t => !t.IsTempTab); + if (tab is null) + { + ImGui.Text("No persistent tab to rename"); + return SelfTestStepResult.Fail; + } + + var original = tab.Name; + var probe = original + "##selftest"; + try + { + if (!TabContextMenu.ApplyTabRename(tab, probe)) + { + ImGui.Text("ApplyTabRename reported no change"); + return SelfTestStepResult.Fail; + } + plugin.SaveConfig(); + + // Reload from disk into a throwaway config; assert the new name landed. + var reloaded = Plugin.Interface.GetPluginConfig() as Configuration; + var match = reloaded?.Tabs.Any(t => t.Name == probe) ?? false; + if (!match) + { + ImGui.Text("Renamed tab not found after reload"); + return SelfTestStepResult.Fail; + } + } + finally + { + tab.Name = original; + plugin.SaveConfig(); + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/TellPillTransparencyStep.cs b/HellionChat/SelfTests/TellPillTransparencyStep.cs new file mode 100644 index 0000000..276a532 --- /dev/null +++ b/HellionChat/SelfTests/TellPillTransparencyStep.cs @@ -0,0 +1,85 @@ +using System; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.GameFunctions.Types; +using HellionChat.Ui.Components; + +namespace HellionChat.SelfTests; + +// v1.8.4: proves the channel pill names the tell partner in the stale-/reply-tell +// state on a NORMAL tab. A game-side tell or reply writes {Channel=Tell, TellTarget} +// onto the active tab's CurrentChannel even when Tab.TellTarget is empty, so the +// isTell pill branch is false. Before the transparency fix the pill showed only +// "Tell (Outgoing)" and hid WHO the next typed line would reach — while BuildOutgoing's +// leg2/leg3 would still /tell that partner. The pill must mirror the exact send target +// (and only when the world resolves, matching the COMP-1 gate) so the user can see and +// avoid a misfire. Restores 1.5.6 transparency. Pure label resolution, no send. +internal sealed class TellPillTransparencyStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public TellPillTransparencyStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - tell pill transparency"; + + public SelfTestStepResult RunStep() + { + // Same deterministic resolvable-world pick as the routing SelfTest. + uint validWorldId = 0; + var worldName = string.Empty; + foreach (var world in Sheets.WorldSheet) + { + if (world.IsPublic && !string.IsNullOrEmpty(world.Name.ToString())) + { + validWorldId = world.RowId; + worldName = world.Name.ToString(); + break; + } + } + + if (validWorldId == 0) + { + ImGui.Text( + "No resolvable public world in the sheet — cannot build the stale-tell case" + ); + return SelfTestStepResult.Fail; + } + + // Stale-/reply-tell shape on a normal tab: current==Tell, Tab.TellTarget + // empty (so isTell is false), CurrentChannel.TellTarget a resolvable partner. + var tab = new Tab(); + tab.CurrentChannel.Channel = InputChannel.Tell; + tab.CurrentChannel.TellTarget = new TellTarget( + "Partner", + validWorldId, + 0, + TellReason.Direct + ); + + // isTell is false here (no IsTempTab + Tab.TellTarget) — exactly the case the + // fix targets, where the old pill collapsed to "Tell (Outgoing)". + var label = InputBar.TestResolvePillLabelForSelfTest(tab, false); + + if (!label.Contains("Partner", StringComparison.Ordinal)) + { + ImGui.Text($"Pill hid the tell partner in the stale-tell state: '{label}'"); + return SelfTestStepResult.Fail; + } + + if (!label.Contains(worldName, StringComparison.Ordinal)) + { + ImGui.Text( + $"Pill omitted the partner world: '{label}' (expected to contain '{worldName}')" + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/TellResetOnActivateStep.cs b/HellionChat/SelfTests/TellResetOnActivateStep.cs new file mode 100644 index 0000000..0784f47 --- /dev/null +++ b/HellionChat/SelfTests/TellResetOnActivateStep.cs @@ -0,0 +1,149 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.GameFunctions.Types; +using HellionChat.Util; + +namespace HellionChat.SelfTests; + +// F1: the activation strip. Drives the REAL OnTabActivated — the entry the +// Sidebar/TopTabBar click handlers, the pop-out path and the Draw-seed all call +// — with local probe tabs (Plugin.Config.Tabs is never touched). Asserts the +// five contracts: strip-on-switch, no-strip-on-reclick (TR-4), leg1 preserve, +// derive, and non-tell untouched. +internal sealed class TellResetOnActivateStep : ISelfTestStep +{ + public string Name => "Hellion Chat - Tell reset on tab activate"; + + public SelfTestStepResult RunStep() + { + var other = MakeSayTab(); + + // (a) switching ONTO a stale-tell tab with no Tab-level binding strips the + // runtime tell state (target + partner label) and re-derives the channel. + var stale = MakeStaleTellTab(boundTellTarget: false, withLabel: true); + TabLifecycleHelpers.OnTabActivated(stale, other); + if (stale.CurrentChannel.TellTarget is not null) + { + ImGui.Text("(a) stale tell target not cleared on switch"); + return SelfTestStepResult.Fail; + } + if (stale.CurrentChannel.Channel != InputChannel.Say) + { + ImGui.Text($"(a) channel not re-derived to Say, got {stale.CurrentChannel.Channel}"); + return SelfTestStepResult.Fail; + } + if (stale.CurrentChannel.Name.Count != 0) + { + ImGui.Text("(a) stale partner label not cleared"); + return SelfTestStepResult.Fail; + } + + // (b) re-clicking the already-active tab (previous == tab) must NOT strip + // a live game-tell conversation (TR-4 regression guard). + var reclick = MakeStaleTellTab(boundTellTarget: false, withLabel: false); + TabLifecycleHelpers.OnTabActivated(reclick, reclick); + if (reclick.CurrentChannel.TellTarget is null) + { + ImGui.Text("(b) re-click wrongly stripped the active tell tab"); + return SelfTestStepResult.Fail; + } + if (reclick.CurrentChannel.Channel != InputChannel.Tell) + { + ImGui.Text("(b) re-click wrongly changed the active tab's channel"); + return SelfTestStepResult.Fail; + } + + // (c) a tab whose own Tab.TellTarget is set is a real binding (leg1): + // channel + runtime target survive a switch. + var bound = MakeStaleTellTab(boundTellTarget: true, withLabel: false); + TabLifecycleHelpers.OnTabActivated(bound, other); + if (bound.CurrentChannel.TellTarget is null) + { + ImGui.Text("(c) bound tell tab wrongly stripped"); + return SelfTestStepResult.Fail; + } + if (bound.CurrentChannel.Channel != InputChannel.Tell) + { + ImGui.Text("(c) bound tell tab channel wrongly changed"); + return SelfTestStepResult.Fail; + } + + // (d) an Invalid-channel tab just derives (pre-existing semantics). + var invalid = MakeSayTab(); + TabLifecycleHelpers.OnTabActivated(invalid, other); + if (invalid.CurrentChannel.Channel != InputChannel.Say) + { + ImGui.Text( + $"(d) invalid-channel tab not derived, got {invalid.CurrentChannel.Channel}" + ); + return SelfTestStepResult.Fail; + } + + // (e) a non-tell tab is left untouched. Seed it with runtime tell state + // AND a label so a guard that wrongly fired on non-tell tabs would null + // them — the channel re-derive alone could not mask that regression. + var say = MakeSayTab(); + say.CurrentChannel.SetChannel(InputChannel.Say); + say.CurrentChannel.TellTarget = new TellTarget("Untouched", 21, 0, TellReason.Direct); + var sayLabel = new SeStringBuilder().AddText("Untouched@World").Build(); + say.CurrentChannel.Name = ChunkUtil + .ToChunks(sayLabel, ChunkSource.Content, ChatType.Say) + .ToList(); + TabLifecycleHelpers.OnTabActivated(say, other); + if (say.CurrentChannel.Channel != InputChannel.Say) + { + ImGui.Text("(e) non-tell tab channel wrongly changed"); + return SelfTestStepResult.Fail; + } + if (say.CurrentChannel.TellTarget is null || say.CurrentChannel.Name.Count == 0) + { + ImGui.Text("(e) non-tell tab runtime state wrongly stripped"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + // A tab carrying runtime tell state the way the game-side detour leaves it: + // CurrentChannel.Channel == Tell with a resolvable CurrentChannel.TellTarget, + // optionally with the partner-name label chunks. boundTellTarget controls + // whether the Tab-level TellTarget marks it a real binding (leg1). + private static Tab MakeStaleTellTab(bool boundTellTarget, bool withLabel) + { + var tab = new Tab + { + Name = "selftest-activate-tell", + TellTarget = boundTellTarget + ? new TellTarget("Bound", 21, 0, TellReason.Direct) + : TellTarget.Empty(), + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + tab.CurrentChannel.SetChannel(InputChannel.Tell); + tab.CurrentChannel.TellTarget = new TellTarget("Stale", 21, 0, TellReason.Direct); + if (withLabel) + { + var ss = new SeStringBuilder().AddText("Stale@World").Build(); + tab.CurrentChannel.Name = ChunkUtil + .ToChunks(ss, ChunkSource.Content, ChatType.Say) + .ToList(); + } + return tab; + } + + private static Tab MakeSayTab() => + new() + { + Name = "selftest-activate-say", + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/TellRoutingBuildStep.cs b/HellionChat/SelfTests/TellRoutingBuildStep.cs new file mode 100644 index 0000000..626179e --- /dev/null +++ b/HellionChat/SelfTests/TellRoutingBuildStep.cs @@ -0,0 +1,140 @@ +using System; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.GameFunctions.Types; +using HellionChat.Util; + +namespace HellionChat.SelfTests; + +// v1.8.4: proves the restored tell routing in InputBar.BuildOutgoing turns a +// tell tab's TellTarget into a full "/tell name@world" instead of the bare "/t" +// the channel prefix would produce. Drives the pure routing via the test hook, +// so it never reaches ChatBox.SendMessageUnsafe (no real chat line) — the actual +// outgoing send stays in-game smoke only. Three cases: +// - Positive: a Tell tab with a TellTarget whose world resolves in the Lumina +// sheet must report wasTell and build the "/tell name@world " prefix. +// - Negative (COMP-1): the same shape but a world id that does NOT resolve must +// report wasTell == false and must NOT build a /tell, so an unresolvable world +// falls back to the channel-prefix path instead of emitting "/tell Name@ text" +// (which the game rejects with "you must add the World name"). +// - Promote guard (CORR-1): a tell tab run through the real promote mutation +// (StripTellBindingOnPromote) must NOT route a typed line as /tell to the old +// partner anymore — the regression guard for the promoted-tab privacy leak. +internal sealed class TellRoutingBuildStep : ISelfTestStep +{ + private readonly Plugin plugin; + + public TellRoutingBuildStep(Plugin plugin) + { + this.plugin = plugin; + } + + public string Name => "Hellion Chat - tell routing build"; + + public SelfTestStepResult RunStep() + { + var input = this.plugin.InputBar; + if (input is null) + { + ImGui.Text("Plugin.InputBar is null"); + return SelfTestStepResult.Fail; + } + + // Pull a resolvable world straight from the sheet instead of hard-coding an + // id — world RowIds shift between patches, so a literal could silently rot. + uint validWorldId = 0; + foreach (var world in Sheets.WorldSheet) + { + if (world.IsPublic && !string.IsNullOrEmpty(world.Name.ToString())) + { + validWorldId = world.RowId; + break; + } + } + + if (validWorldId == 0) + { + ImGui.Text("No resolvable public world in the sheet — cannot build the positive case"); + return SelfTestStepResult.Fail; + } + + // Positive: a Tell tab with a resolvable target builds the full /tell prefix. + var tellTab = new Tab(); + tellTab.CurrentChannel.Channel = InputChannel.Tell; + tellTab.TellTarget = new TellTarget("Testchar", validWorldId, 0, TellReason.Direct); + + var (toSend, wasTell) = input.TestBuildOutgoingForSelfTest(tellTab, "ping"); + if (!wasTell) + { + ImGui.Text( + "Positive: BuildOutgoing reported wasTell == false for a resolvable tell tab" + ); + return SelfTestStepResult.Fail; + } + + var expectedPrefix = $"/tell Testchar@{tellTab.TellTarget.ToWorldString()} "; + if (!toSend.StartsWith(expectedPrefix, StringComparison.Ordinal)) + { + ImGui.Text($"Positive: expected prefix '{expectedPrefix}', got '{toSend}'"); + return SelfTestStepResult.Fail; + } + + // Negative (COMP-1): a world id that does not resolve must NOT become a /tell. + var missTab = new Tab(); + missTab.CurrentChannel.Channel = InputChannel.Tell; + missTab.TellTarget = new TellTarget("Testchar", uint.MaxValue, 0, TellReason.Direct); + + var (missSend, missWasTell) = input.TestBuildOutgoingForSelfTest(missTab, "ping"); + if (missWasTell) + { + ImGui.Text("Negative COMP-1: wasTell == true for a world id that does not resolve"); + return SelfTestStepResult.Fail; + } + + if (missSend.StartsWith("/tell ", StringComparison.Ordinal)) + { + ImGui.Text($"Negative COMP-1: built a /tell for an unresolvable world: '{missSend}'"); + return SelfTestStepResult.Fail; + } + + // Promote guard (CORR-1): build the pre-promote leak shape — a pinned tell + // tab whose CurrentChannel still carries Channel=Tell + a resolvable target + // — run the REAL promote mutation, then BuildOutgoing must not produce a + // /tell to the old partner. If StripTellBindingOnPromote ever stops clearing + // the runtime channel, this turns red. + var promoteTab = new Tab(); + promoteTab.IsTempTab = true; + promoteTab.IsPinned = true; + promoteTab.Channel = InputChannel.Tell; + promoteTab.TellTarget = new TellTarget("Oldpartner", validWorldId, 0, TellReason.Direct); + promoteTab.CurrentChannel.Channel = InputChannel.Tell; + promoteTab.CurrentChannel.TellTarget = promoteTab.TellTarget.Clone(); + + TabLifecycleHelpers.StripTellBindingOnPromote(promoteTab); + + var (promotedSend, promotedWasTell) = input.TestBuildOutgoingForSelfTest( + promoteTab, + "ping" + ); + if (promotedWasTell) + { + ImGui.Text( + "Promote guard (CORR-1): a promoted tab still routes as /tell to the old partner" + ); + return SelfTestStepResult.Fail; + } + + if (promotedSend.StartsWith("/tell ", StringComparison.Ordinal)) + { + ImGui.Text( + $"Promote guard (CORR-1): built a /tell to the old partner: '{promotedSend}'" + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/ThemePickerCategoryStep.cs b/HellionChat/SelfTests/ThemePickerCategoryStep.cs new file mode 100644 index 0000000..79efe48 --- /dev/null +++ b/HellionChat/SelfTests/ThemePickerCategoryStep.cs @@ -0,0 +1,57 @@ +using System.Linq; +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; +using HellionChat.Themes; +using HellionChat.Ui.Components.Settings; + +namespace HellionChat.SelfTests; + +internal sealed class ThemePickerCategoryStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public ThemePickerCategoryStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - Theme picker category coverage"; + + public SelfTestStepResult RunStep() + { + var builtinSlugs = _plugin.ThemeRegistry.BuiltinSlugs.ToHashSet(); + var categorySlugs = ThemePicker.CategoryMapSlugs.ToList(); + + var duplicates = categorySlugs + .GroupBy(x => x) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + if (duplicates.Count > 0) + { + ImGui.Text($"Duplicate slugs in category map: {string.Join(", ", duplicates)}"); + return SelfTestStepResult.Fail; + } + + var categorySet = categorySlugs.ToHashSet(); + var missing = builtinSlugs.Except(categorySet).ToList(); + var unknown = categorySet.Except(builtinSlugs).ToList(); + + if (missing.Count > 0) + { + ImGui.Text($"Builtin slugs missing from category map: {string.Join(", ", missing)}"); + return SelfTestStepResult.Fail; + } + if (unknown.Count > 0) + { + ImGui.Text( + $"Unknown slugs in category map (no matching builtin): {string.Join(", ", unknown)}" + ); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/TypingIpcStateStep.cs b/HellionChat/SelfTests/TypingIpcStateStep.cs new file mode 100644 index 0000000..b0fa4d1 --- /dev/null +++ b/HellionChat/SelfTests/TypingIpcStateStep.cs @@ -0,0 +1,73 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +internal sealed class TypingIpcStateStep : ISelfTestStep +{ + private readonly Plugin _plugin; + + public TypingIpcStateStep(Plugin plugin) + { + _plugin = plugin; + } + + public string Name => "Hellion Chat - TypingIpc state reflects input bar"; + + public SelfTestStepResult RunStep() + { + // /xlperf typically runs without MainWindow open. TypingIpc.BuildState gates + // InputFocused on MainWindow.IsOpen (stale-state guard); without this setup + // InputFocused would be false regardless of the hook. Restore in finally so + // the test leaves no UI side-effect. + var initialMainWindowOpen = _plugin.MainWindow.IsOpen; + if (!initialMainWindowOpen) + { + _plugin.MainWindow.Toggle(); + } + + // Snapshot pending so we restore in-flight user input verbatim. + var initialPendingMessage = _plugin.InputBar.PendingMessage; + _plugin.InputBar.TestSetPendingMessageForSelfTest("hello"); + _plugin.InputBar.TestSetFocusedForSelfTest(true); + + try + { + var state = _plugin.TypingIpc.GetState(); + + if (!state.HasText) + { + ImGui.Text("HasText should be true"); + return SelfTestStepResult.Fail; + } + if (!state.IsTyping) + { + ImGui.Text("IsTyping should be true"); + return SelfTestStepResult.Fail; + } + if (state.TextLength != 5) + { + ImGui.Text($"TextLength should be 5, got {state.TextLength}"); + return SelfTestStepResult.Fail; + } + if (!state.InputFocused) + { + ImGui.Text("InputFocused should be true"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + finally + { + _plugin.InputBar.TestSetPendingMessageForSelfTest(initialPendingMessage); + _plugin.InputBar.TestSetFocusedForSelfTest(null); + if (!initialMainWindowOpen) + { + _plugin.MainWindow.Toggle(); + } + } + } + + public void CleanUp() { } +} diff --git a/HellionChat/SelfTests/UnreadDecisionStep.cs b/HellionChat/SelfTests/UnreadDecisionStep.cs new file mode 100644 index 0000000..82222e9 --- /dev/null +++ b/HellionChat/SelfTests/UnreadDecisionStep.cs @@ -0,0 +1,66 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Plugin.SelfTest; + +namespace HellionChat.SelfTests; + +// F3: the unread decision (MessageManager.ShouldCountUnread). Unseen suppresses +// unread on an inactive tab only when the active tab ALSO shows the message (you +// saw it there) — 1.5.6/upstream semantics, now measured against the REAL active +// tab thanks to F2. Asserts the truth table: suppressed when active tab also +// matches; counts when it does not (the Carla/Jin case); All always counts; None +// counts at the increment layer (the display gate hides it). +internal sealed class UnreadDecisionStep : ISelfTestStep +{ + public string Name => "Hellion Chat - Unread decision (per active tab)"; + + public SelfTestStepResult RunStep() + { + var active = new Tab { Name = "active", UnreadMode = UnreadMode.Unseen }; + var inactive = new Tab { Name = "inactive", UnreadMode = UnreadMode.Unseen }; + + // (a) inactive Unseen tab + the active tab ALSO shows the message + // (currentTabMatches=true) => suppressed (you saw it in the active tab). + if (MessageManager.ShouldCountUnread(inactive, active, currentTabMatches: true)) + { + ImGui.Text("(a) inactive Unseen tab must be suppressed when active tab also shows it"); + return SelfTestStepResult.Fail; + } + + // (b) inactive Unseen tab + the active tab does NOT show the message + // (currentTabMatches=false) => counts (badge). The Carla/Jin case. + if (!MessageManager.ShouldCountUnread(inactive, active, currentTabMatches: false)) + { + ImGui.Text("(b) inactive Unseen tab must count when the active tab does not show it"); + return SelfTestStepResult.Fail; + } + + // (c) the active tab itself counts here (current==tab short-circuits the + // suppression); the draw loop zeroes it so no dot is ever shown. + if (!MessageManager.ShouldCountUnread(active, active, currentTabMatches: true)) + { + ImGui.Text("(c) active tab should count at the increment layer (draw loop zeroes it)"); + return SelfTestStepResult.Fail; + } + + // (d) All-mode always counts, regardless of currentTabMatches. + var all = new Tab { Name = "all", UnreadMode = UnreadMode.All }; + if (!MessageManager.ShouldCountUnread(all, active, currentTabMatches: true)) + { + ImGui.Text("(d) All-mode tab should always count unread"); + return SelfTestStepResult.Fail; + } + + // (e) None counts at the increment layer (the None opt-out lives in the + // display gate, not here). + var none = new Tab { Name = "none", UnreadMode = UnreadMode.None }; + if (!MessageManager.ShouldCountUnread(none, active, currentTabMatches: true)) + { + ImGui.Text("(e) None should count at the increment layer (display gates it)"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + public void CleanUp() { } +} diff --git a/HellionChat/Services/TellRouterService.cs b/HellionChat/Services/TellRouterService.cs new file mode 100644 index 0000000..ce081d0 --- /dev/null +++ b/HellionChat/Services/TellRouterService.cs @@ -0,0 +1,112 @@ +using HellionChat.Code; +using HellionChat.Util; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Services; + +// Routes an incoming tell to the configured TellAutoOpenMode (Off/Sidebar/ +// TopTab/Popout). Decoupled from AutoTellTabsService (Flo decision 2026-06-15): +// that service owns tab CREATION + lifecycle; this only REVEALS/pops the tab it +// finds. Popout guards on pool.IsOpen so it never double-pops a tab the +// AutoTellTabsOpenAsPopout path already opened. Subscribes to the resolved +// MessageManager.MessageProcessed stream (a resolved Message), not the raw +// IChatGui event, and defers the reveal one tick so the tab exists regardless of +// subscriber order. Wired by TellRouterServiceInitHostedService. +internal sealed class TellRouterService : IDisposable +{ + private readonly MessageManager _messageManager; + private readonly ILogger _logger; + private bool _initialized; + + public TellRouterService(MessageManager messageManager, ILogger logger) + { + _messageManager = messageManager; + _logger = logger; + } + + public void Initialize() + { + if (_initialized) + return; + + _messageManager.MessageProcessed += OnMessageProcessed; + _initialized = true; + _logger.LogDebug("TellRouterService online; routing incoming tells by TellAutoOpenMode."); + } + + public void Dispose() + { + if (!_initialized) + return; + + _messageManager.MessageProcessed -= OnMessageProcessed; + _initialized = false; + } + + private void OnMessageProcessed(Message message) + { + var mode = Plugin.Config.TellAutoOpenMode; + if (mode == TellAutoOpenMode.Off) + return; + + if (message.Code.Type != ChatType.TellIncoming) + return; + + // Partner = sender for an incoming tell. Same payload idiom AutoTellTabs uses + // (AutoTellTabsService.ExtractTellPartner), so the lookup never diverges. + var partner = + ChunkUtil.TryGetPlayerPayload(message.Sender) + ?? ChunkUtil.TryGetPlayerPayload(message.SenderSource); + if (partner == null) + return; + + var name = partner.PlayerName; + var world = partner.World.RowId; + + // Defer the reveal to the next framework tick. AutoTellTabsService also + // handles this MessageProcessed (synchronously); by the next tick the tab + // exists regardless of subscription order, and the reveal (ActivateTab / pool + // mutation) is serialized with Draw (reference_dalamud_framework_thread). + Plugin.Framework.RunOnFrameworkThread(() => + { + // Lock-safe lookup: AutoTellTabs mutates Config.Tabs under its lock on the + // worker thread, so we read through its guarded accessor, not the static. + var tab = Plugin.Instance.AutoTellTabsService?.FindTempTabSafe(name, world); + if (tab == null) + return; // nothing to reveal (auto-tell-tabs off -> no tab created) + + switch (mode) + { + case TellAutoOpenMode.Sidebar: + case TellAutoOpenMode.TopTab: + // Switching to the tab on every tell is user-gated + // (TellAutoOpenSwitchAlways, default on); when off the tab still + // appears with its unread badge but the active tab is left alone. + // The mode also picks the layout, so Sidebar vs TopTab are actually + // distinct outcomes, not the same ActivateTab. + if (Plugin.Config.TellAutoOpenSwitchAlways) + { + var wantLayout = + mode == TellAutoOpenMode.TopTab + ? MainWindowLayoutMode.TopTabs + : MainWindowLayoutMode.Sidebar; + if (Plugin.Config.MainWindowLayoutMode != wantLayout) + { + Plugin.Config.MainWindowLayoutMode = wantLayout; + Plugin.Instance.SaveConfig(); + } + + Plugin.Instance.MainWindow?.ActivateTab(tab); + } + + break; + case TellAutoOpenMode.Popout: + // IsOpen-guard: don't double-pop a tab the AutoTellTabsOpenAsPopout + // path already opened (the two switches stay decoupled). + if (!Plugin.Instance.ChannelPopoutPool.IsOpen(tab.Identifier)) + Plugin.Instance.ChannelPopoutPool.TryOpen(tab); + break; + } + }); + } +} diff --git a/HellionChat/Themes/Builtin/example-theme.json b/HellionChat/Themes/Builtin/example-theme.json index 5489cec..7b6f7de 100644 --- a/HellionChat/Themes/Builtin/example-theme.json +++ b/HellionChat/Themes/Builtin/example-theme.json @@ -1,5 +1,5 @@ { - "schemaVersion": 1, + "schemaVersion": 2, "slug": "example-custom", "name": "Example Custom", "author": "You", @@ -37,5 +37,9 @@ "scrollbarRounding": 2, "windowBorderSize": 1, "frameBorderSize": 1 + }, + "typography": { + "overrideGlobalFontSizePt": null, + "overrideSymbolsFontSizePt": null } } diff --git a/HellionChat/Themes/ThemeJsonLoader.cs b/HellionChat/Themes/ThemeJsonLoader.cs index 88a4c32..b61db75 100644 --- a/HellionChat/Themes/ThemeJsonLoader.cs +++ b/HellionChat/Themes/ThemeJsonLoader.cs @@ -1,13 +1,20 @@ using System.Text.Json; +using HellionChat.Themes.Builtin; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Themes; internal static class ThemeJsonLoader { - public const int SupportedSchemaVersion = 1; + public const int SupportedSchemaVersion = 2; - public static Theme LoadFromString(string json) + // Returns null when the file declares an older schemaVersion. Hard-cut + // policy from the v2.x style refactor: v1 user themes are not migrated, + // they're silently ignored so the loader stays free of legacy mapping + // code. Any other malformed input still throws FormatException. + // B4b-2: callers must pass the logger or the default-fill warnings go silent. + public static Theme? LoadFromString(string json, ILogger? logger = null) { if (string.IsNullOrWhiteSpace(json)) throw new FormatException("Theme JSON is empty"); @@ -27,9 +34,11 @@ internal static class ThemeJsonLoader var root = doc.RootElement; var schemaVersion = ReadInt(root, "schemaVersion"); - if (schemaVersion != SupportedSchemaVersion) + if (schemaVersion < SupportedSchemaVersion) + return null; + if (schemaVersion > SupportedSchemaVersion) throw new FormatException( - $"Unsupported schemaVersion {schemaVersion}; expected {SupportedSchemaVersion}" + $"Unsupported schemaVersion {schemaVersion}; this build reads up to {SupportedSchemaVersion}" ); var slug = ReadString(root, "slug"); @@ -37,8 +46,23 @@ internal static class ThemeJsonLoader var author = ReadString(root, "author"); var description = ReadString(root, "description"); - var colors = ReadColors(root.GetProperty("colors")); - var layout = ReadLayout(root.GetProperty("layout")); + // Missing colours/layout object stays fatal, but as FormatException so the + // import path catches it — GetProperty's KeyNotFoundException would crash. + if ( + !root.TryGetProperty("colors", out var colorsEl) + || colorsEl.ValueKind != JsonValueKind.Object + ) + throw new FormatException("Theme JSON missing 'colors' object"); + if ( + !root.TryGetProperty("layout", out var layoutEl) + || layoutEl.ValueKind != JsonValueKind.Object + ) + throw new FormatException("Theme JSON missing 'layout' object"); + + var fallback = HellionArctic.Build(); + var colors = ReadColors(colorsEl, fallback.Colors, logger); + var layout = ReadLayout(layoutEl, fallback.Layout, logger); + var typography = ReadTypography(root); ThemeChatColors? chatColors = null; if ( @@ -54,7 +78,7 @@ internal static class ThemeJsonLoader description, colors, layout, - new ThemeTypography(), + typography, IsBuiltIn: false, ChatColors: chatColors ); @@ -86,54 +110,88 @@ internal static class ThemeJsonLoader return new ThemeChatColors(dict); } - public static Theme LoadFromFile(string path) + public static Theme? LoadFromFile(string path, ILogger? logger = null) { // FileShare.Read lets concurrent readers and well-behaved editors share // the handle; atomic-replace editors still raise IOException, caught upstream. using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); using var reader = new StreamReader(stream); var json = reader.ReadToEnd(); - return LoadFromString(json); + return LoadFromString(json, logger); } - private static ThemeColors ReadColors(JsonElement el) => + private static ThemeColors ReadColors(JsonElement el, ThemeColors fallback, ILogger? logger) => new( - PrimaryDark: ColourUtil.HexToRgba(ReadString(el, "primaryDark")), - Primary: ColourUtil.HexToRgba(ReadString(el, "primary")), - PrimaryLight: ColourUtil.HexToRgba(ReadString(el, "primaryLight")), - PrimaryGlow: ColourUtil.HexToRgba(ReadString(el, "primaryGlow")), - AccentDark: ColourUtil.HexToRgba(ReadString(el, "accentDark")), - Accent: ColourUtil.HexToRgba(ReadString(el, "accent")), - AccentLight: ColourUtil.HexToRgba(ReadString(el, "accentLight")), - Identity: ColourUtil.HexToRgba(ReadString(el, "identity")), - WindowBg: ColourUtil.HexToRgba(ReadString(el, "windowBg")), - ChildBg: ColourUtil.HexToRgba(ReadString(el, "childBg")), - FrameBg: ColourUtil.HexToRgba(ReadString(el, "frameBg")), - Surface: ColourUtil.HexToRgba(ReadString(el, "surface")), - SurfaceHover: ColourUtil.HexToRgba(ReadString(el, "surfaceHover")), - Border: ColourUtil.HexToRgba(ReadString(el, "border")), - TextPrimary: ColourUtil.HexToRgba(ReadString(el, "textPrimary")), - TextMuted: ColourUtil.HexToRgba(ReadString(el, "textMuted")), - TextDim: ColourUtil.HexToRgba(ReadString(el, "textDim")), - StatusSuccess: ColourUtil.HexToRgba(ReadString(el, "statusSuccess")), - StatusDanger: ColourUtil.HexToRgba(ReadString(el, "statusDanger")), - StatusWarning: ColourUtil.HexToRgba(ReadString(el, "statusWarning")), - StatusInfo: ColourUtil.HexToRgba(ReadString(el, "statusInfo")) + PrimaryDark: ReadColorOrDefault(el, "primaryDark", fallback.PrimaryDark, logger), + Primary: ReadColorOrDefault(el, "primary", fallback.Primary, logger), + PrimaryLight: ReadColorOrDefault(el, "primaryLight", fallback.PrimaryLight, logger), + PrimaryGlow: ReadColorOrDefault(el, "primaryGlow", fallback.PrimaryGlow, logger), + AccentDark: ReadColorOrDefault(el, "accentDark", fallback.AccentDark, logger), + Accent: ReadColorOrDefault(el, "accent", fallback.Accent, logger), + AccentLight: ReadColorOrDefault(el, "accentLight", fallback.AccentLight, logger), + Identity: ReadColorOrDefault(el, "identity", fallback.Identity, logger), + WindowBg: ReadColorOrDefault(el, "windowBg", fallback.WindowBg, logger), + ChildBg: ReadColorOrDefault(el, "childBg", fallback.ChildBg, logger), + FrameBg: ReadColorOrDefault(el, "frameBg", fallback.FrameBg, logger), + Surface: ReadColorOrDefault(el, "surface", fallback.Surface, logger), + SurfaceHover: ReadColorOrDefault(el, "surfaceHover", fallback.SurfaceHover, logger), + Border: ReadColorOrDefault(el, "border", fallback.Border, logger), + TextPrimary: ReadColorOrDefault(el, "textPrimary", fallback.TextPrimary, logger), + TextMuted: ReadColorOrDefault(el, "textMuted", fallback.TextMuted, logger), + TextDim: ReadColorOrDefault(el, "textDim", fallback.TextDim, logger), + StatusSuccess: ReadColorOrDefault(el, "statusSuccess", fallback.StatusSuccess, logger), + StatusDanger: ReadColorOrDefault(el, "statusDanger", fallback.StatusDanger, logger), + StatusWarning: ReadColorOrDefault(el, "statusWarning", fallback.StatusWarning, logger), + StatusInfo: ReadColorOrDefault(el, "statusInfo", fallback.StatusInfo, logger) ); - private static ThemeLayout ReadLayout(JsonElement el) => + private static ThemeLayout ReadLayout(JsonElement el, ThemeLayout fallback, ILogger? logger) => new( - WindowRounding: ReadFloat(el, "windowRounding"), - ChildRounding: ReadFloat(el, "childRounding"), - PopupRounding: ReadFloat(el, "popupRounding"), - FrameRounding: ReadFloat(el, "frameRounding"), - GrabRounding: ReadFloat(el, "grabRounding"), - TabRounding: ReadFloat(el, "tabRounding"), - ScrollbarRounding: ReadFloat(el, "scrollbarRounding"), - WindowBorderSize: ReadFloat(el, "windowBorderSize"), - FrameBorderSize: ReadFloat(el, "frameBorderSize") + WindowRounding: ReadFloatOrDefault( + el, + "windowRounding", + fallback.WindowRounding, + logger + ), + ChildRounding: ReadFloatOrDefault(el, "childRounding", fallback.ChildRounding, logger), + PopupRounding: ReadFloatOrDefault(el, "popupRounding", fallback.PopupRounding, logger), + FrameRounding: ReadFloatOrDefault(el, "frameRounding", fallback.FrameRounding, logger), + GrabRounding: ReadFloatOrDefault(el, "grabRounding", fallback.GrabRounding, logger), + TabRounding: ReadFloatOrDefault(el, "tabRounding", fallback.TabRounding, logger), + ScrollbarRounding: ReadFloatOrDefault( + el, + "scrollbarRounding", + fallback.ScrollbarRounding, + logger + ), + WindowBorderSize: ReadFloatOrDefault( + el, + "windowBorderSize", + fallback.WindowBorderSize, + logger + ), + FrameBorderSize: ReadFloatOrDefault( + el, + "frameBorderSize", + fallback.FrameBorderSize, + logger + ) ); + // Optional in v2 — themes without a typography block default to the + // record's parameterless construction (both override slots null). A + // present-but-empty object also yields the default. + private static ThemeTypography ReadTypography(JsonElement root) + { + if (!root.TryGetProperty("typography", out var el) || el.ValueKind != JsonValueKind.Object) + return new ThemeTypography(); + + return new ThemeTypography( + OverrideGlobalFontSizePt: ReadOptionalFloat(el, "overrideGlobalFontSizePt"), + OverrideSymbolsFontSizePt: ReadOptionalFloat(el, "overrideSymbolsFontSizePt") + ); + } + private static string ReadString(JsonElement el, string name) { if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String) @@ -154,4 +212,65 @@ internal static class ThemeJsonLoader throw new FormatException($"Theme JSON missing number property '{name}'"); return (float)v.GetDouble(); } + + private static float? ReadOptionalFloat(JsonElement el, string name) + { + if (!el.TryGetProperty(name, out var v)) + return null; + if (v.ValueKind == JsonValueKind.Null) + return null; + if (v.ValueKind != JsonValueKind.Number) + throw new FormatException($"Theme JSON property '{name}' must be a number or null"); + return (float)v.GetDouble(); + } + + // Missing / wrong-typed / unparseable colour slot -> built-in default + one warning. + private static uint ReadColorOrDefault( + JsonElement el, + string name, + uint fallback, + ILogger? logger + ) + { + if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.String) + { + logger?.LogWarning( + "Theme JSON colour slot '{Slot}' missing or not a string, using built-in default", + name + ); + return fallback; + } + + try + { + return ColourUtil.HexToRgba(v.GetString()!); + } + catch (FormatException) + { + logger?.LogWarning( + "Theme JSON colour slot '{Slot}' has an invalid hex value, using built-in default", + name + ); + return fallback; + } + } + + private static float ReadFloatOrDefault( + JsonElement el, + string name, + float fallback, + ILogger? logger + ) + { + if (!el.TryGetProperty(name, out var v) || v.ValueKind != JsonValueKind.Number) + { + logger?.LogWarning( + "Theme JSON layout slot '{Slot}' missing or not a number, using built-in default", + name + ); + return fallback; + } + + return (float)v.GetDouble(); + } } diff --git a/HellionChat/Themes/ThemeJsonWriter.cs b/HellionChat/Themes/ThemeJsonWriter.cs index f693a49..356c5ed 100644 --- a/HellionChat/Themes/ThemeJsonWriter.cs +++ b/HellionChat/Themes/ThemeJsonWriter.cs @@ -52,6 +52,22 @@ internal static class ThemeJsonWriter writer.WriteNumber("frameBorderSize", theme.Layout.FrameBorderSize); writer.WriteEndObject(); + // Typography always written so a hand-edited file shows the + // available knobs even when the user has not picked any + // override yet. + writer.WriteStartObject("typography"); + WriteOptionalFloat( + writer, + "overrideGlobalFontSizePt", + theme.Typography.OverrideGlobalFontSizePt + ); + WriteOptionalFloat( + writer, + "overrideSymbolsFontSizePt", + theme.Typography.OverrideSymbolsFontSizePt + ); + writer.WriteEndObject(); + if (theme.ChatColors is { Channels.Count: > 0 } cc) { writer.WriteStartObject("chatChannels"); @@ -70,4 +86,12 @@ internal static class ThemeJsonWriter { writer.WriteString(key, $"#{rgba:X8}"); } + + private static void WriteOptionalFloat(Utf8JsonWriter writer, string key, float? value) + { + if (value.HasValue) + writer.WriteNumber(key, value.Value); + else + writer.WriteNull(key); + } } diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index cbac2c1..f8cb3f2 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using HellionChat.Themes.Builtin; using Microsoft.Extensions.Logging; @@ -42,6 +43,43 @@ public sealed class ThemeRegistry private long _crossfadeStartTickMs = long.MinValue; private const int CrossfadeDurationMs = 300; + private Theme? _editingThemeBuffer; + public Theme? EditingThemeBuffer => _editingThemeBuffer; + public event Action? OnEditingBufferChanged; + + // Fired after _active changes (Switch / RefreshActiveIfStale); the init host + // wires it to the font-atlas rebuild. NOT fired by SwitchSilent (boot handles that). + private Action? _onActiveChanged; + + internal void SetActiveChangedCallback(Action callback) => _onActiveChanged = callback; + + // Shared slug guard for any code path that turns a slug into a filename. + // Both SaveEditingBuffer (F1) and ImportFromPath (M6) call this so the + // path-traversal/invalid-char rules live in exactly one place. + // + // Whitespace rejection is intentional: Path.GetInvalidFileNameChars on + // POSIX only flags NUL and '/', so a slug like "foo bar" would pass the + // platform check yet break URL-safety and cross-platform portability. + // Slugs are user-visible identifiers that may end up in filenames on + // Windows + Linux, in config keys, and in JSON — keeping them whitespace- + // free dodges the whole class of "did the user mean this or that" bugs. + internal static bool IsSafeThemeSlug(string? slug) + { + if (string.IsNullOrWhiteSpace(slug)) + return false; + + foreach (var c in slug) + { + if (char.IsWhiteSpace(c)) + return false; + } + + return !slug.Contains("..", StringComparison.Ordinal) + && !slug.Contains('/') + && !slug.Contains('\\') + && slug.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; + } + public ThemeRegistry(string? customThemesDir = null, ILogger? logger = null) { _logger = logger; @@ -73,6 +111,49 @@ public sealed class ThemeRegistry public Theme Active => _active; + // Read-only exposure of the configured custom themes directory. + // M6 ThemeImportExportRow opens this path via Process.Start. + public string? CustomThemesDir => _customThemesDir; + + // Read-only enumeration of all built-in theme slugs. T2 ThemePickerCategoryStep + // diffs this set against ThemePicker.CategoryMapSlugs to enforce coverage. + public IEnumerable BuiltinSlugs => _builtIns.Keys; + + // True try-pattern lookup: returns false when neither built-in nor custom + // cache holds the slug, no fallback to default. M3 ThemePicker uses this + // for card-rendering, M6 ThemeImportExportRow for fork-slug collisions. + // Cold-cache fallback: see `LoadCustomBySlug` lookup-by-slug reverse + // iteration — it only walks the pre-populated _customCache. If a freshly + // imported file has not been enumerated yet (or no warm-up ran), the first + // lookup would miss silently. Drain RefreshCustomCache once on miss so the + // custom file gets picked up before the second lookup. + public bool TryGet(string slug, out Theme theme) + { + if (_builtIns.TryGetValue(slug, out var b)) + { + theme = b; + return true; + } + + var custom = LoadCustomBySlug(slug, out _); + if (custom is null) + { + // Force-enumerate the yield-iterator so _customCache picks up any + // file that landed in the themes dir since the last warm-up. + foreach (var _ in RefreshCustomCache()) { } + custom = LoadCustomBySlug(slug, out _); + } + + if (custom is not null) + { + theme = custom; + return true; + } + + theme = null!; + return false; + } + public Theme Get(string slug) { if (_builtIns.TryGetValue(slug, out var b)) @@ -102,6 +183,12 @@ public sealed class ThemeRegistry if (string.Equals(_active.Slug, slug, StringComparison.OrdinalIgnoreCase)) return; + if (_editingThemeBuffer is not null) + { + DiscardEditingBuffer(); + _logger?.LogWarning("Theme switch to {Slug} discarded unsaved edits", slug); + } + ArmCrossfade(); if (_builtIns.TryGetValue(slug, out var builtin)) @@ -109,27 +196,32 @@ public sealed class ThemeRegistry _active = builtin; _active.RecomputeAbgrCache(); _activeCustomPath = null; - return; } - - var customTheme = LoadCustomBySlug(slug, out var customPath); - if (customTheme is not null) + else { - _active = customTheme; - // Defensive — ensures any future theme source always gets a populated cache. - _active.RecomputeAbgrCache(); - _activeCustomPath = customPath; - // Force a first-tick reload-check after the switch so the stamp - // baseline is established on the next RefreshActiveIfStale call. - _lastActiveStamp = DateTime.MinValue; - return; + var customTheme = LoadCustomBySlug(slug, out var customPath); + if (customTheme is not null) + { + _active = customTheme; + // Defensive — ensures any future theme source always gets a populated cache. + _active.RecomputeAbgrCache(); + _activeCustomPath = customPath; + // Force a first-tick reload-check after the switch so the stamp + // baseline is established on the next RefreshActiveIfStale call. + _lastActiveStamp = DateTime.MinValue; + } + else + { + // Fallback: neither built-in nor custom matched. Drop to default + // and clear the active custom path so RefreshActiveIfStale stays idle. + _active = _builtIns[DefaultSlug]; + _active.RecomputeAbgrCache(); + _activeCustomPath = null; + } } - // Fallback: neither built-in nor custom matched. Drop to default - // and clear the active custom path so RefreshActiveIfStale stays idle. - _active = _builtIns[DefaultSlug]; - _active.RecomputeAbgrCache(); - _activeCustomPath = null; + // Notify listeners (the init host wires the font-atlas rebuild here). + _onActiveChanged?.Invoke(); } // SwitchSilent is the plugin-load init path -- identical to Switch @@ -142,6 +234,11 @@ public sealed class ThemeRegistry if (string.Equals(_active.Slug, slug, StringComparison.OrdinalIgnoreCase)) return; + if (_editingThemeBuffer is not null) + { + DiscardEditingBuffer(); + } + if (_builtIns.TryGetValue(slug, out var builtin)) { _active = builtin; @@ -165,6 +262,251 @@ public sealed class ThemeRegistry _activeCustomPath = null; } + public void BeginEditing(Theme source) + { + // Shallow record-with-clone: Theme.Colors gets an explicit second-level + // with-copy so ColorPicker edits never mutate the source record. Layout + // and Typography are value-record-clean (only primitive fields). Chat- + // Colors stays a reference share because the editor never touches + // ChatColors. If a future cycle adds a ChatColors editor, + // BeginEditing must also clone the channel dictionary + // (ThemeChatColors holds IReadOnlyDictionary). + _editingThemeBuffer = source with + { + Colors = source.Colors with { }, + }; + } + + public void UpdateEditingBuffer(ThemeColors newColors) + { + if (_editingThemeBuffer is null) + { + return; + } + + _editingThemeBuffer = _editingThemeBuffer with { Colors = newColors }; + OnEditingBufferChanged?.Invoke(); + } + + // CALLER CONTRACT: the buffer slug must NOT collide with a built-in slug. + // Switch() prefers built-ins over custom themes with the same slug + // (see `Switch` built-in-first lookup), so saving a custom file under + // a built-in slug persists the file but leaves the built-in active — + // looks green, behaves broken. M4 ColorPicker DrawIdleState forks + // built-in themes into a custom slug before BeginEditing, M6 + // ImportFromPath renames built-in-colliding imports to _imported. + // New call-sites must either fork first or rename to a non-built-in slug. + public bool SaveEditingBuffer(out string targetPath) + { + targetPath = string.Empty; + if (_editingThemeBuffer is null || _customThemesDir is null) + { + return false; + } + + // Slug ends up as a filename below — refuse anything that contains path + // separators, parent-directory tokens, or platform-invalid filename chars. + // Without this guard an imported theme with Slug "../../../etc/passwd" + // would let Path.Combine escape _customThemesDir entirely. Shared helper + // so M6 ImportFromPath uses the exact same rule set. + var safeSlug = _editingThemeBuffer.Slug; + if (!IsSafeThemeSlug(safeSlug)) + { + _logger?.LogWarning( + "Refusing to save editing buffer with unsafe slug {Slug}", + safeSlug + ); + return false; + } + + // Safe-by-construction: refuse any slug that collides with a built-in + // BEFORE we touch the disk. Switch() prefers built-ins over custom files + // with the same slug (see `Switch` built-in-first lookup). Without this + // reject a mis-routed caller (or a future bug in ImportFromPath) could + // persist a custom file under a built-in slug — the file lands on disk, + // Switch keeps the built-in active, and the post-save active-slug check + // below returns false. The caller then sees "save failed" while a garbage + // file accumulates in the themes dir on every retry. M4 ColorPicker forks + // built-in themes into a custom slug before BeginEditing, M6 ImportFromPath + // renames built-in-colliding imports to _imported, so production + // paths already steer clear; this guard catches everything else. + if (_builtIns.ContainsKey(safeSlug)) + { + _logger?.LogWarning( + "Refusing to save editing buffer under built-in slug {Slug}", + safeSlug + ); + return false; + } + + try + { + targetPath = Path.Combine(_customThemesDir, $"{safeSlug}.json"); + + // Defence in depth: even after the character-level scrub above, make + // sure the resolved full path is still rooted in _customThemesDir. + // Catches edge cases like alternate data streams or symlink-style + // tricks the loader could otherwise follow. + var fullDir = Path.GetFullPath(_customThemesDir); + var fullTarget = Path.GetFullPath(targetPath); + if ( + !fullTarget.StartsWith( + fullDir + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase + ) + ) + { + _logger?.LogWarning( + "Theme save target {Target} escapes themes dir {Dir}", + fullTarget, + fullDir + ); + return false; + } + + var json = ThemeJsonWriter.Serialize(_editingThemeBuffer); + + // Atomic-replace: write to a sibling .tmp file first, then File.Move + // with overwrite=true. POSIX rename() and Windows MoveFileEx with + // MOVEFILE_REPLACE_EXISTING are both atomic on the same volume — a + // mid-write crash (power loss, Wine kill, OOM) leaves either the + // previous content or the new content on disk, never a partial JSON + // that would silently disappear at next Plugin-Start through the + // ThemeJsonLoader catch-and-continue path inside RefreshCustomCache. + var tmpPath = targetPath + ".tmp"; + File.WriteAllText(tmpPath, json); + try + { + File.Move(tmpPath, targetPath, overwrite: true); + } + catch + { + // Avoid `.tmp` litter when Move fails (target locked by AV + // scanner, EXDEV cross-device, share-violation). Best-effort + // delete, then rethrow so the outer IOException catch still + // reports the failure. + try + { + File.Delete(tmpPath); + } + catch + { + // best-effort cleanup + } + throw; + } + + // Note: the redundant `_lastActiveStamp = DateTime.MinValue` reset from + // the earlier plan-draft was removed — Switch() itself already resets + // _lastActiveStamp on the custom-theme path (see `Switch` + // custom-theme branch resets `_lastActiveStamp`) as part of the + // active-switch, so a pre-Switch reset is overwritten anyway. + + // `RefreshCustomCache` is a yield-iterator (see its `yield return` + // body) — a bare call would build the iterator but never enumerate + // it, so the cache side-effect (_customCache[key] = (theme, stamp)) + // would never run. Force-enumerate so the subsequent Switch() finds + // the freshly saved file. + foreach (var _ in RefreshCustomCache()) { } + + // Use the sanitised slug for Switch() too — the buffer's raw Slug + // already passed the guard, but staying on safeSlug keeps the lookup + // value consistent with the on-disk filename we just wrote. + var targetSlug = safeSlug; + + // CRITICAL: null the buffer BEFORE Switch() so the Switch-Guard + // (step 3d) does not fire on our own save-internal Switch call. + // Without this pre-nullify the guard would log a misleading + // "discarded unsaved edits" warning on every save and run + // DiscardEditingBuffer twice (once in the guard, once at method end). + _editingThemeBuffer = null; + + Switch(targetSlug); + + // Same-slug in-place edit: Switch() hits its same-slug noop + // early-return (see `Switch` same-slug noop early-return) and + // leaves _active pointing at the PRE-edit Theme reference. The + // newly saved colours would only surface on the next + // RefreshActiveIfStale tick (1Hz-throttled, up to ~1s lag). + // Force-pull the freshly-cached Theme directly so the post-Save + // UI sees the edit in the next frame. + if (string.Equals(_active.Slug, targetSlug, StringComparison.OrdinalIgnoreCase)) + { + var reloaded = LoadCustomBySlug(targetSlug, out _); + if (reloaded is not null) + { + reloaded.RecomputeAbgrCache(); + _active = reloaded; + // Same-slug save bypasses Switch's notify (it noop'd on same slug); + // fire here so a typography change applies (no-op if size unchanged). + _onActiveChanged?.Invoke(); + } + } + + // Switch() falls back to DefaultSlug when neither built-in nor custom + // matches (see `Switch` default-slug fallback at the end of the + // method). Verify we actually landed on the intended theme before + // reporting success — a silent fallback to the default would + // otherwise mask a save that did persist the file but failed to + // become active (e.g. cache race on slow disks). + if (!string.Equals(_active.Slug, targetSlug, StringComparison.OrdinalIgnoreCase)) + { + // Log filename-only (not the full path) here — the path includes + // the user's home directory which counts as PII. Forensics-critical + // log calls above (path-escape detection) keep the full paths + // because diagnosing the escape needs the resolved target. Memory + // anchor: feedback_hellion_chat_changelog (v1.8.0 PII re-audit + // roadmap). + _logger?.LogWarning( + "SaveEditingBuffer persisted {File} but Switch landed on {Active} instead of {Target}", + Path.GetFileName(targetPath), + _active.Slug, + targetSlug + ); + return false; + } + + return true; + } + catch (IOException ex) + { + _logger?.LogWarning( + ex, + "I/O error saving editing buffer to {File}", + Path.GetFileName(targetPath) + ); + return false; + } + catch (UnauthorizedAccessException ex) + { + _logger?.LogWarning( + ex, + "Access denied saving editing buffer to {File}", + Path.GetFileName(targetPath) + ); + return false; + } + catch (JsonException ex) + { + // ThemeJsonWriter.Serialize could in principle throw on malformed + // theme graphs; keep this granular so transient I/O and serialisation + // failures don't get lumped together with future structural bugs. + // Requires `using System.Text.Json;` at the top of ThemeRegistry.cs + // — verify before saving and add the import if it's not yet present. + _logger?.LogWarning( + ex, + "JSON serialisation failed for editing buffer at {File}", + Path.GetFileName(targetPath) + ); + return false; + } + } + + public void DiscardEditingBuffer() + { + _editingThemeBuffer = null; + } + // Captures the AbgrCache snapshot that PushGlobal should fade FROM. // If a crossfade is already mid-flight (second Switch within 300ms), // the current lerped state replaces the snapshot -- the next fade @@ -240,6 +582,7 @@ public sealed class ThemeRegistry // RecomputeAbgrCache happens inside RefreshCustomCache on cache miss. var reloaded = Get(_active.Slug); _active = reloaded; + _onActiveChanged?.Invoke(); } // 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION. @@ -298,9 +641,14 @@ public sealed class ThemeRegistry { try { - theme = ThemeJsonLoader.LoadFromFile(path); - theme.RecomputeAbgrCache(); - _customCache[key] = (theme, stamp); + theme = ThemeJsonLoader.LoadFromFile(path, _logger); + // null = hard-cut policy skipped a legacy v1 file. Leave + // theme null so the yield-guard below drops the entry. + if (theme is not null) + { + theme.RecomputeAbgrCache(); + _customCache[key] = (theme, stamp); + } } catch (Exception ex) when (IsRecoverableFileLock(ex)) { diff --git a/HellionChat/Themes/ThemeTypography.cs b/HellionChat/Themes/ThemeTypography.cs index 9f7a981..b889f56 100644 --- a/HellionChat/Themes/ThemeTypography.cs +++ b/HellionChat/Themes/ThemeTypography.cs @@ -1,6 +1,7 @@ namespace HellionChat.Themes; // Optional per-theme; reserved as an extension point for future theme slots. +// Italic body-size override intentionally omitted (v1.9.0 Typography-Polish). public sealed record ThemeTypography( float? OverrideGlobalFontSizePt = null, float? OverrideSymbolsFontSizePt = null diff --git a/HellionChat/Ui/AutoCompleteInfo.cs b/HellionChat/Ui/AutoCompleteInfo.cs deleted file mode 100755 index 2d7418c..0000000 --- a/HellionChat/Ui/AutoCompleteInfo.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace HellionChat.Ui; - -internal class AutoCompleteInfo -{ - internal string ToComplete; - internal int StartPos { get; } - internal int EndPos { get; } - - internal AutoCompleteInfo(string toComplete, int startPos, int endPos) - { - ToComplete = toComplete; - StartPos = startPos; - EndPos = endPos; - } -} diff --git a/HellionChat/Ui/AutoTellTabTint.cs b/HellionChat/Ui/AutoTellTabTint.cs deleted file mode 100644 index d6b26f2..0000000 --- a/HellionChat/Ui/AutoTellTabTint.cs +++ /dev/null @@ -1,70 +0,0 @@ -namespace HellionChat.Ui; - -// Deterministic hash-based color and icon tinting for Auto-Tell sidebar tabs. -// Same tell partner (name+world) always produces the same color and icon across -// sessions. Pure string logic, no Dalamud dependency — testable without game refs. -internal static class AutoTellTabTint -{ - // Fallback for invalid input (empty name or world=0). White matches - // TextPrimary default so the sidebar stays visually consistent. - public const uint Fallback = 0xFFFFFFFFu; - - // 12 saturated mid-bright colors from the built-in theme pool, readable - // on dark backgrounds. Collision risk is low at realistic 1-5 active tells. - // RGBA format, matching ColourUtil.RgbaToAbgr convention. - public static readonly IReadOnlyList Palette = new uint[] - { - 0x00BED2FFu, // Arctic Cyan - 0xF97316FFu, // Ember Orange - 0xB585FFFFu, // Light Cosmic Purple - 0xE374E8FFu, // Bloom Magenta - 0x5DD39EFFu, // Mint Green - 0xF0AD4EFFu, // Warning Yellow - 0xE85C6AFFu, // Coral - 0x5CB85CFFu, // Status Green - 0x6278FFFFu, // Bloom Blue - 0xC9982EFFu, // Warm Gold - 0x9CCB7CFFu, // Soft Sage - 0xE85D04FFu, // Deep Ember - }; - - public static uint For(string name, uint world) - { - if (string.IsNullOrEmpty(name) || world == 0) - return Fallback; - - // Mask to positive range so modulo always yields a valid index. - var key = $"{name}@{world}"; - var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF); - return Palette[(int)(hash % Palette.Count)]; - } - - // 7 visually distinct FA glyphs that make sense in a tell context. - // Excludes cog/comment/users — those read as system or group tabs. - public static readonly IReadOnlyList IconPool = new[] - { - "envelope", - "star", - "heart", - "bell", - "bookmark", - "flag", - "fire", - }; - - // "envelope" matches the tell context better than the old hardcoded "clock". - public const string IconFallback = "envelope"; - - public static string IconFor(string name, uint world) - { - if (string.IsNullOrEmpty(name) || world == 0) - return IconFallback; - - // Reversed key ("world@name") gives icon and color independent variation - // so the same tell partner doesn't always get the same color+icon pair. - // 7 icons x 12 colors = 84 distinct combinations. - var key = $"{world}@{name}"; - var hash = (uint)(key.GetHashCode() & 0x7FFFFFFF); - return IconPool[(int)(hash % IconPool.Count)]; - } -} diff --git a/HellionChat/Ui/ChatInputBar.cs b/HellionChat/Ui/ChatInputBar.cs deleted file mode 100644 index 3359f81..0000000 --- a/HellionChat/Ui/ChatInputBar.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System; -using System.Numerics; -using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Colors; -using Dalamud.Interface.Utility.Raii; -using HellionChat._Helpers; -using HellionChat.Code; -using HellionChat.Resources; -using HellionChat.Util; - -namespace HellionChat.Ui; - -// Input bar component for pop-out windows. Render() is a stub — the main -// window input layer stays in ChatLogWindow to avoid a high-risk extract. -// RenderCompact() is the only v0.6.0 deliverable; Render() can be filled -// in a later cycle if needed. -public sealed class ChatInputBar -{ - private readonly Plugin _plugin; - private readonly ChatLogWindow _host; - private readonly Func _activeTabAccessor; - private readonly InputState _state = new(); - - // UI-11: the buffer for which a plugin-disclosure warning was already - // shown. A second Enter on the same buffer sends it anyway; editing the - // buffer clears the arming so the next send is re-checked. - private string? _disclosureArmedBuffer; - - public ChatInputBar(Plugin plugin, ChatLogWindow host, Func activeTabAccessor) - { - _plugin = plugin; - _host = host; - _activeTabAccessor = activeTabAccessor; - } - - public InputState State => _state; - public bool IsFocused { get; private set; } - - // Stub — main window input is handled in ChatLogWindow. - public void Render() { } - - // Compact layout for pop-out windows: channel icon button left, text - // input right. Auto-translate is intentionally excluded — the upstream - // popup isn't instanciable per window without a larger refactor, and - // typical pop-out use cases rarely need it. Can be added later if - // tester feedback warrants it. - // - // Channel switching is global via Plugin.Functions.Chat (FFXIV API). - // Text buffer and history cursor are independent per pop-out. - public void RenderCompact() - { - var tab = _activeTabAccessor(); - if (tab == null) - return; - - DrawChannelIconButton(tab); - ImGui.SameLine(); - DrawCompactInput(tab); - } - - private void DrawCompactInput(Tab tab) - { - var inputWidth = ImGui.GetContentRegionAvail().X; - if (inputWidth < 60f) - inputWidth = 60f; - - ImGui.SetNextItemWidth(inputWidth); - - // CallbackHistory wires Up/Down navigation to InputHistoryService. - // Submit detected via IsItemDeactivated + Enter, not EnterReturnsTrue - // (matches ChatLogWindow behavior). - const ImGuiInputTextFlags flags = ImGuiInputTextFlags.CallbackHistory; - ImGui.InputText( - $"##chat-compact-input-{tab.Identifier}", - ref _state.Buffer, - 500, - flags, - CompactCallback - ); - - IsFocused = ImGui.IsItemActive(); - - if ( - ImGui.IsItemDeactivated() - && (ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter)) - ) - { - SubmitCompact(tab); - } - - // UI-11: disclosure warning, visible only while an armed buffer is held - // unchanged. Editing the buffer clears the condition automatically. - if ( - Plugin.Config.NotifyPluginDisclosure - && _disclosureArmedBuffer is not null - && _state.Buffer == _disclosureArmedBuffer - ) - { - ImGui.TextColored( - ImGuiColors.DalamudYellow, - HellionStrings.ChatInput_PluginDisclosure_Warning - ); - } - } - - // TEST-MIRROR: ../_Helpers/CompactInputSubmitter.cs - private void SubmitCompact(Tab tab) - { - if ( - Plugin.Config.NotifyPluginDisclosure - && _state.Buffer != _disclosureArmedBuffer - && PluginDisclosureScanner.ContainsPrivateUseGlyph(_state.Buffer) - ) - { - // First send attempt on this exact buffer: arm and hold. The buffer - // is kept, the warning renders, the user can press Enter again. - _disclosureArmedBuffer = _state.Buffer; - return; - } - - _disclosureArmedBuffer = null; - CompactInputSubmitter.TrySubmit(_state, tab, _host.SendChatBoxFromExternal); - } - - // History navigation callback. Cursor math delegated to - // CompactInputHistoryNavigator; ImGui buffer splice stays here. - // TEST-MIRROR: ../_Helpers/CompactInputHistoryNavigator.cs - private int CompactCallback(scoped ref ImGuiInputTextCallbackData data) - { - if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory) - return 0; - - var direction = data.EventKey switch - { - ImGuiKey.UpArrow => CompactInputHistoryNavigator.Direction.Up, - ImGuiKey.DownArrow => CompactInputHistoryNavigator.Direction.Down, - _ => (CompactInputHistoryNavigator.Direction?)null, - }; - if (direction is null) - return 0; - - var (cursor, replacement) = CompactInputHistoryNavigator.Navigate( - direction.Value, - _state.HistoryCursor, - _state.Buffer, - () => InputHistoryService.Count, - InputHistoryService.Push, - InputHistoryService.GetByCursor - ); - - _state.HistoryCursor = cursor; - if (replacement is null) - return 0; - - data.DeleteChars(0, data.BufTextLen); - data.InsertChars(0, replacement); - return 0; - } - - private void DrawChannelIconButton(Tab tab) - { - var inputType = tab.CurrentChannel.UseTempChannel - ? tab.CurrentChannel.TempChannel.ToChatType() - : tab.CurrentChannel.Channel.ToChatType(); - - var rgba = Plugin.Config.ChatColours.TryGetValue(inputType, out var c) - ? c - : (inputType.DefaultColor() ?? 0xFFFFFFFFu); - var v3 = ColourUtil.RgbaToVector3(rgba); - var bg = new Vector4(v3.X, v3.Y, v3.Z, 1f); - - // Black foreground on bright backgrounds, white on dark. - var luminance = 0.2126f * v3.X + 0.7152f * v3.Y + 0.0722f * v3.Z; - var fg = luminance > 0.55f ? new Vector4(0f, 0f, 0f, 1f) : new Vector4(1f, 1f, 1f, 1f); - - const string popupId = "chat-channel-picker-compact"; - const float buttonSize = 22f; - - using (ImRaii.PushColor(ImGuiCol.Button, bg)) - using (ImRaii.PushColor(ImGuiCol.ButtonHovered, bg)) - using (ImRaii.PushColor(ImGuiCol.ButtonActive, bg)) - using (ImRaii.PushColor(ImGuiCol.Text, fg)) - { - // Single-letter glyph as a quick visual cue until a proper icon font lands. - var label = ChannelGlyph(inputType); - if ( - ImGui.Button($"{label}##chan-compact", new Vector2(buttonSize, buttonSize)) - && tab.Channel is null - ) - ImGui.OpenPopup(popupId); - } - - if (tab.Channel is not null && ImGui.IsItemHovered()) - ImGui.SetTooltip(Resources.Language.ChatLog_SwitcherDisabled); - else if (ImGui.IsItemHovered()) - ImGui.SetTooltip(inputType.Name()); - - using (var popup = ImRaii.Popup(popupId)) - { - if (popup) - { - var channels = _host.GetValidChannels(); - foreach (var (name, channel) in channels) - if (ImGui.Selectable(name)) - _host.SetChannel(channel); - } - } - } - - private static string ChannelGlyph(ChatType type) => - type switch - { - ChatType.Say => "S", - ChatType.Yell => "Y", - ChatType.Shout => "!", - ChatType.TellIncoming or ChatType.TellOutgoing => "T", - ChatType.Party or ChatType.CrossParty => "P", - ChatType.Alliance => "A", - ChatType.FreeCompany => "F", - ChatType.NoviceNetwork => "N", - ChatType.Linkshell1 => "1", - ChatType.Linkshell2 => "2", - ChatType.Linkshell3 => "3", - ChatType.Linkshell4 => "4", - ChatType.Linkshell5 => "5", - ChatType.Linkshell6 => "6", - ChatType.Linkshell7 => "7", - ChatType.Linkshell8 => "8", - ChatType.CrossLinkshell1 => "①", - ChatType.CrossLinkshell2 => "②", - ChatType.CrossLinkshell3 => "③", - ChatType.CrossLinkshell4 => "④", - ChatType.CrossLinkshell5 => "⑤", - ChatType.CrossLinkshell6 => "⑥", - ChatType.CrossLinkshell7 => "⑦", - ChatType.CrossLinkshell8 => "⑧", - _ => "?", - }; - - // Forwards a tab-cycle keybind delta to the host (single source of truth). - public void HandleKeybindForward(int delta) => _host.ChangeTabDelta(delta); -} - -// Per-window input state. Each ChatInputBar owns one so pop-outs and the -// main window keep independent buffers and history cursors. -public sealed class InputState -{ - public string Buffer = string.Empty; - public InputChannel? Channel; - public int HistoryCursor = -1; -} diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs deleted file mode 100644 index 6cc5e5a..0000000 --- a/HellionChat/Ui/ChatLogWindow.cs +++ /dev/null @@ -1,3295 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Numerics; -using System.Runtime.InteropServices; -using System.Text; -using Dalamud.Bindings.ImGui; -using Dalamud.Game.Addon.Lifecycle; -using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Game.Text.SeStringHandling.Payloads; -using Dalamud.Interface; -using Dalamud.Interface.Colors; -using Dalamud.Interface.Style; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Raii; -using Dalamud.Interface.Windowing; -using Dalamud.Memory; -using FFXIVClientStructs.FFXIV.Client.UI; -using FFXIVClientStructs.FFXIV.Client.UI.Agent; -using HellionChat._Helpers; -using HellionChat.Code; -using HellionChat.GameFunctions; -using HellionChat.GameFunctions.Types; -using HellionChat.Integrations; -using HellionChat.Resources; -using HellionChat.Util; -using Lumina.Excel.Sheets; -using Lumina.Extensions; -using Microsoft.Extensions.Logging; - -namespace HellionChat.Ui; - -public sealed class ChatLogWindow : Window -{ - private const string ChatChannelPicker = "chat-channel-picker"; - private const string AutoCompleteId = "##chat2-autocomplete"; - - private const ImGuiInputTextFlags InputFlags = - ImGuiInputTextFlags.CallbackAlways - | ImGuiInputTextFlags.CallbackCharFilter - | ImGuiInputTextFlags.CallbackCompletion - | ImGuiInputTextFlags.CallbackHistory; - - internal Plugin Plugin { get; } - - private readonly CommandWrapper _clearHellionCommand; - private readonly CommandWrapper _hellionCommand; - private readonly SymbolPicker _symbolPicker; - - internal bool ScreenshotMode; - private string Salt { get; } - - internal Vector4 DefaultText { get; set; } - - internal bool FocusedPreview; - internal bool Activate; - internal bool InputFocused { get; private set; } - private int ActivatePos = -1; - internal string Chat = string.Empty; - - // UI-11: the main-window input buffer for which a plugin-disclosure - // warning was already shown. Mirrors _disclosureArmedBuffer in - // ChatInputBar — a second Enter on the same buffer sends it anyway. - private string? _disclosureArmedBufferMain; - - // Input history extracted into InputHistoryService so pop-out windows share - // the same Up/Down history. Cursor stays window-local (independent navigation). - private int InputBacklogIdx = -1; - public bool TellSpecial; - private readonly Stopwatch LastResize = new(); - private AutoCompleteInfo? AutoCompleteInfo; - private bool AutoCompleteOpen; - private List? AutoCompleteList; - private bool FixCursor; - private int AutoCompleteSelection; - private bool AutoCompleteShouldScroll; - - // Used to detect channel changes for the webinterface - public Chunk[] PreviousChannel = []; - - public int CursorPos; - - public Vector2 LastWindowPos { get; private set; } = Vector2.Zero; - public Vector2 LastWindowSize { get; private set; } = Vector2.Zero; - - // Guards against off-screen positions after a display layout change. - // One-shot bounds check on first draw; manual reset button bypasses it. - private bool DidOnLoadBoundsCheck; - internal bool RequestPositionReset { get; set; } - - public unsafe ImGuiViewport* LastViewport; - private bool WasDocked; - - public PayloadHandler PayloadHandler { get; } - internal Lender HandlerLender { get; } - private Dictionary TextCommandChannels { get; } = new(); - private Dictionary AllCommands { get; } = []; - - private const uint ChatOpenSfx = 35u; - private const uint ChatCloseSfx = 3u; - private bool PlayedClosingSound = true; - private bool DrewThisFrame; - - // One-shot guard so a recurring draw failure doesn't spam the - // notification stack frame-by-frame. Resets only on next plugin reload. - private bool NotifiedDrawFailure; - - private long FrameTime; // set every frame - internal long LastActivityTime = Environment.TickCount64; - - private readonly ILogger _logger; - private readonly ILoggerFactory _loggerFactory; - - internal ChatLogWindow( - Plugin plugin, - ILogger logger, - ILoggerFactory loggerFactory - ) - : base($"{Plugin.PluginName}###chat2") - { - Plugin = plugin; - _logger = logger; - _loggerFactory = loggerFactory; - Salt = new Random().Next().ToString(); - - Size = new Vector2(500, 250); - SizeCondition = ImGuiCond.FirstUseEver; - - PositionCondition = ImGuiCond.Always; - - IsOpen = true; - RespectCloseHotkey = false; - DisableWindowSounds = true; - // AllowBackgroundBlur is set centrally in Plugin.Setup after AddWindow. - - PayloadHandler = new PayloadHandler(this, _loggerFactory.CreateLogger()); - HandlerLender = new Lender(() => - new PayloadHandler(this, _loggerFactory.CreateLogger()) - ); - - SetUpTextCommandChannels(); - SetUpAllCommands(); - - // Cache wrapper instances so Dispose can detach the same event objects - // without going through Register() again. - _clearHellionCommand = Plugin.Commands.Register( - "/clearhellion", - "Clear the Hellion Chat log" - ); - _hellionCommand = Plugin.Commands.Register("/hellion"); - _clearHellionCommand.Execute += ClearLog; - _hellionCommand.Execute += ToggleChat; - - _symbolPicker = new SymbolPicker(); - - Plugin.ClientState.Login += Login; - Plugin.ClientState.Logout += Logout; - - Plugin.AddonLifecycle.RegisterListener( - AddonEvent.PostUpdate, - "ItemDetail", - PayloadHandler.MoveTooltip - ); - Plugin.AddonLifecycle.RegisterListener( - AddonEvent.PostUpdate, - "ActionDetail", - PayloadHandler.MoveTooltip - ); - } - - public void Dispose() - { - Plugin.AddonLifecycle.UnregisterListener( - AddonEvent.PostUpdate, - "ItemDetail", - PayloadHandler.MoveTooltip - ); - Plugin.AddonLifecycle.UnregisterListener( - AddonEvent.PostUpdate, - "ActionDetail", - PayloadHandler.MoveTooltip - ); - Plugin.ClientState.Logout -= Logout; - Plugin.ClientState.Login -= Login; - _hellionCommand.Execute -= ToggleChat; - _clearHellionCommand.Execute -= ClearLog; - } - - private void Logout(int _, int __) - { - Plugin.MessageManager.ClearAllTabs(); - } - - private void Login() - { - Plugin.MessageManager.FilterAllTabsAsync(); - } - - internal unsafe void Activated(ChatActivatedArgs args) - { - TellSpecial = args.TellSpecial; - - Activate = true; - PlayedClosingSound = false; - if (Plugin.Config.PlaySounds) - UIGlobals.PlaySoundEffect(ChatOpenSfx); - - // Don't set the channel or text content when activating a disabled tab. - if (Plugin.CurrentTab.InputDisabled) - { - // The closing sound would've been immediately played in this case. - PlayedClosingSound = true; - return; - } - - // --------------------------------------------------------------- - // Cherry-picked from ChatTwo upstream ee7768ac (Infiziert90, 2026-05-16) - // - Replace the chat input when args.AddIfNotPresent / args.Input starts - // with a slash. Vanilla actions like the Friend List "/tell" entry and - // other plugins push slash commands through these args; appending them - // to existing text would produce inputs like "test/tell user@world". - // --------------------------------------------------------------- - if (args.AddIfNotPresent != null && !Chat.Contains(args.AddIfNotPresent)) - { - if (args.AddIfNotPresent.StartsWith('/')) - Chat = args.AddIfNotPresent; - else - Chat += args.AddIfNotPresent; - } - - if (args.Input != null) - { - if (args.Input.StartsWith('/')) - Chat = args.Input; - else - Chat += args.Input; - } - - var (info, reason, target) = (args.ChannelSwitchInfo, args.TellReason, args.TellTarget); - - if (info.Channel != null) - { - var targetChannel = info.Channel; - if (info.Channel is InputChannel.Tell) - { - if (info.Rotate != RotateMode.None) - { - var idx = - Plugin.CurrentTab.CurrentChannel.TempChannel != InputChannel.Tell ? 0 - : info.Rotate == RotateMode.Reverse ? -1 - : 1; - - var tellInfo = Plugin.Functions.Chat.GetTellHistoryInfo(idx); - if (tellInfo != null && reason != null) - Plugin.CurrentTab.CurrentChannel.TempTellTarget = new TellTarget( - tellInfo.Name, - (ushort)tellInfo.World, - tellInfo.ContentId, - reason.Value - ); - } - else - { - Plugin.CurrentTab.CurrentChannel.TellTarget = null; - if (target != null) - { - if (info.Permanent) - { - Plugin.CurrentTab.CurrentChannel.TellTarget = target; - } - else - { - Plugin.CurrentTab.CurrentChannel.UseTempChannel = true; - Plugin.CurrentTab.CurrentChannel.TempTellTarget = target; - } - } - } - } - else - { - Plugin.CurrentTab.CurrentChannel.TellTarget = null; - } - - if ( - info.Channel is InputChannel.Linkshell1 or InputChannel.CrossLinkshell1 - && info.Rotate != RotateMode.None - ) - { - var module = UIModule.Instance(); - - // If any of these operations fail, do nothing. - if (info.Permanent) - { - // Rotate using the game's code. - if (info.Channel == InputChannel.Linkshell1) - { - GameFunctions.Chat.RotateLinkshellHistory(info.Rotate); - targetChannel = info.Channel + (uint)module->LinkshellCycle; - } - else - { - GameFunctions.Chat.RotateCrossLinkshellHistory(info.Rotate); - targetChannel = info.Channel + (uint)module->CrossWorldLinkshellCycle; - } - } - else - { - targetChannel = GameFunctions.Chat.ResolveTempInputChannel( - Plugin.CurrentTab.CurrentChannel.TempChannel, - info.Channel.Value, - info.Rotate - ); - } - } - - if ( - targetChannel == null - || !GameFunctions.Chat.IsChannelOrExistingLinkshell(targetChannel.Value) - ) - { - _logger.LogWarning( - $"Channel was set to an invalid value '{targetChannel}', ignoring" - ); - return; - } - - if (info.Permanent) - { - SetChannel(targetChannel); - } - else - { - Plugin.CurrentTab.CurrentChannel.UseTempChannel = true; - Plugin.CurrentTab.CurrentChannel.TempChannel = targetChannel.Value; - } - } - - if (info.Text != null && Chat.Length == 0) - Chat = info.Text; - } - - private bool IsValidCommand(string command) - { - return Plugin.CommandManager.Commands.ContainsKey(command) - || AllCommands.ContainsKey(command); - } - - private void ClearLog(string command, string arguments) - { - switch (arguments) - { - case "all": - Plugin.MessageManager.ClearAllTabs(); - break; - case "help": - Plugin.ChatGui.Print("- /clearlog2: clears the active tab's log"); - Plugin.ChatGui.Print( - "- /clearlog2 all: clears all tabs' logs and the global history" - ); - Plugin.ChatGui.Print("- /clearlog2 help: shows this help"); - break; - default: - if (Plugin.LastTab > -1 && Plugin.LastTab < Plugin.Config.Tabs.Count) - Plugin.Config.Tabs[Plugin.LastTab].Clear(); - break; - } - } - - private void ToggleChat(string _, string arguments) - { - switch (arguments) - { - case "hide": - CurrentHideState = HideState.User; - _logger.LogTrace("HideState: → User (chat hide command)"); - break; - case "show": - CurrentHideState = HideState.None; - _logger.LogTrace("HideState: → None (chat show command)"); - break; - case "toggle": - CurrentHideState = CurrentHideState switch - { - HideState.User or HideState.CutsceneOverride => HideState.None, - HideState.Cutscene => HideState.CutsceneOverride, - HideState.None => HideState.User, - _ => CurrentHideState, - }; - _logger.LogTrace($"HideState: → {CurrentHideState} (chat toggle command)"); - break; - } - } - - private void SetUpTextCommandChannels() - { - TextCommandChannels.Clear(); - - foreach (var input in Enum.GetValues()) - { - var commands = input.TextCommands(); - if (commands == null) - continue; - - var type = input.ToChatType(); - foreach (var command in commands) - AddTextCommandChannel(command, type); - } - - if (Sheets.TextCommandSheet.TryGetRow(116, out var row)) - AddTextCommandChannel(row, ChatType.Echo); - } - - private void AddTextCommandChannel(TextCommand command, ChatType type) - { - TextCommandChannels[command.Command.ExtractText()] = type; - TextCommandChannels[command.ShortCommand.ExtractText()] = type; - TextCommandChannels[command.Alias.ExtractText()] = type; - TextCommandChannels[command.ShortAlias.ExtractText()] = type; - } - - private void SetUpAllCommands() - { - foreach (var command in Sheets.TextCommandSheet) - { - if (!command.Command.IsEmpty) - AllCommands.TryAdd(command.Command.ToString(), command); - - if (!command.ShortCommand.IsEmpty) - AllCommands.TryAdd(command.ShortCommand.ToString(), command); - - if (!command.Alias.IsEmpty) - AllCommands.TryAdd(command.Alias.ToString(), command); - - if (!command.ShortAlias.IsEmpty) - AllCommands.TryAdd(command.ShortAlias.ToString(), command); - } - } - - // Delegates to InputHistoryService so pop-out ChatInputBar instances share - // history. Deduplication lives inside the service. - private void AddBacklog(string message) - { - InputHistoryService.Push(message); - } - - private float GetRemainingHeightForMessageLog() - { - var lineHeight = ImGui.CalcTextSize("A").Y; - var height = - ImGui.GetContentRegionAvail().Y - - lineHeight * 2 - - ImGui.GetStyle().ItemSpacing.Y - - ImGui.GetStyle().FramePadding.Y * 2; - - if (Plugin.Config.PreviewPosition is PreviewPosition.Inside) - height -= Plugin.InputPreview.PreviewHeight; - - // Header toolbar height is not subtracted by GetContentRegionAvail automatically - // (it renders outside the normal layout path), so we subtract it explicitly. - // The hint banner renders before this block so ImGui already accounts for it. - height -= ImGui.GetFrameHeightWithSpacing(); - - // StatusBar.Height now bakes in its own DPI-aware 2px spacer, so the - // window reservation is just Height -- no extra +2 (v1.4.8 B1). - height -= StatusBar.Height; - - return height; - } - - internal void ChangeTab(int index) - { - Plugin.WantedTab = index; - LastActivityTime = FrameTime; - } - - internal void ChangeTabDelta(int offset) - { - var newIndex = (Plugin.LastTab + offset) % Plugin.Config.Tabs.Count; - while (newIndex < 0) - newIndex += Plugin.Config.Tabs.Count; - ChangeTab(newIndex); - } - - // PM-2b v1.5.4 header quick-picker. Two scrollable sections -- every - // built-in plus custom theme, and every tab. Clicking a theme arms - // the PM-1 crossfade via ThemeRegistry.Switch; clicking a tab routes - // through ChangeTab so LastActivityTime stays consistent with the - // sidebar and top-bar click paths. DontClosePopups keeps the popup - // open so the user can hop between entries without re-opening it. - private void DrawQuickPickerPopup() - { - using var popup = ImRaii.Popup("##hellion-quick-picker"); - if (!popup.Success) - return; - - ImGui.TextUnformatted(HellionStrings.Settings_QuickPicker_Themes_Header); - ImGui.Separator(); - - var activeSlug = Plugin.ThemeRegistry.Active.Slug; - var allThemes = Plugin - .ThemeRegistry.AllBuiltIns() - .Concat(Plugin.ThemeRegistry.AllCustom()) - .ToList(); - - using ( - var scroll = ImRaii.Child( - "##hellion-quick-picker-themes", - new Vector2(220f, Math.Min(allThemes.Count * 22f, 200f)) - ) - ) - { - if (scroll.Success) - { - foreach (var theme in allThemes) - { - var isActive = string.Equals( - theme.Slug, - activeSlug, - StringComparison.OrdinalIgnoreCase - ); - DrawQuickPickerGlyph(isActive); - if ( - ImGui.Selectable( - $"{theme.Name}##quick-theme-{theme.Slug}", - isActive, - ImGuiSelectableFlags.DontClosePopups - ) && !isActive - ) - Plugin.ThemeRegistry.Switch(theme.Slug); - } - } - } - - ImGui.Spacing(); - ImGui.TextUnformatted(HellionStrings.Settings_QuickPicker_Tabs_Header); - ImGui.Separator(); - - var tabs = Plugin.Config.Tabs; - var activeTabIndex = Plugin.LastTab; - using ( - var scroll = ImRaii.Child( - "##hellion-quick-picker-tabs", - new Vector2(220f, Math.Min(tabs.Count * 22f, 200f)) - ) - ) - { - if (scroll.Success) - { - for (var i = 0; i < tabs.Count; i++) - { - var isActive = i == activeTabIndex; - DrawQuickPickerGlyph(isActive); - if ( - ImGui.Selectable( - $"{tabs[i].Name}##quick-tab-{i}", - isActive, - ImGuiSelectableFlags.DontClosePopups - ) && !isActive - ) - ChangeTab(i); - } - } - } - } - - // Leading check-glyph slot for a quick-picker row. Active rows get a - // FontAwesome check; inactive rows get a same-width blank so the - // labels stay aligned. The glyph font push stays on its own line so - // it never bleeds into the body-font Selectable label. - private void DrawQuickPickerGlyph(bool isActive) - { - using (Plugin.FontManager.FontAwesome.Push()) - { - var check = FontAwesomeIcon.Check.ToIconString(); - if (isActive) - ImGui.TextUnformatted(check); - else - ImGui.Dummy(new Vector2(ImGui.CalcTextSize(check).X, ImGui.GetTextLineHeight())); - } - ImGui.SameLine(); - } - - private void TabSwitched(Tab newTab, Tab previousTab) - { - // Use the fixed channel if set by the user. Otherwise, if the new tab - // has no channel state yet (fresh from JSON, never selected this - // session), seed from the previous tab — but deep-clone so we don't - // share TellTarget with the previous tab. Without the clone, a later - // /tell on the new tab would mutate the pinned tab's TellTarget and - // the Party/Linkshell channel would pop back to the pinned tell-mark. - if (newTab.Channel is not null) - { - newTab.CurrentChannel.Channel = newTab.Channel.Value; - } - else if (newTab.CurrentChannel.Channel is InputChannel.Invalid) - { - newTab.CurrentChannel = previousTab.CurrentChannel.Clone(); - _logger.LogDebug( - $"[Tab] '{newTab.Name}' seeded channel from '{previousTab.Name}' " - + $"(Channel={newTab.CurrentChannel.Channel}, TellTarget={newTab.CurrentChannel.TellTarget?.ToTargetString() ?? "null"})" - ); - } - - SetChannel(newTab.CurrentChannel.Channel); - } - - private enum HideState - { - None, - Cutscene, - CutsceneOverride, - User, - Battle, - } - - private HideState CurrentHideState = HideState.None; - - public bool IsHidden; - - public void HideStateCheck() - { - // if the chat has no hide state set, and the player has entered battle, we hide chat if they have configured it - if (Plugin.Config.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle) - { - CurrentHideState = HideState.Battle; - _logger.LogTrace("HideState: None → Battle"); - } - - // If the chat is hidden because of battle, we reset it here - if (CurrentHideState is HideState.Battle && !Plugin.InBattle) - { - CurrentHideState = HideState.None; - _logger.LogTrace("HideState: Battle → None"); - } - - // if the chat has no hide state and in a cutscene, set the hide state to cutscene - if ( - Plugin.Config.HideDuringCutscenes - && CurrentHideState == HideState.None - && (Plugin.CutsceneActive || Plugin.GposeActive) - ) - { - if (Plugin.Functions.Chat.CheckHideFlags()) - { - CurrentHideState = HideState.Cutscene; - _logger.LogTrace("HideState: None → Cutscene"); - } - } - - // if the chat is hidden because of a cutscene and no longer in a cutscene, set the hide state to none - if ( - CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride - && !Plugin.CutsceneActive - && !Plugin.GposeActive - ) - { - _logger.LogTrace($"HideState: {CurrentHideState} → None (cutscene/gpose ended)"); - CurrentHideState = HideState.None; - } - - // if the chat is hidden because of a cutscene and the chat has been activated, show chat - if (CurrentHideState == HideState.Cutscene && Activate) - { - CurrentHideState = HideState.CutsceneOverride; - _logger.LogTrace("HideState: Cutscene → CutsceneOverride (user activate)"); - } - - // if the user hid the chat and is now activating chat, reset the hide state - if (CurrentHideState == HideState.User && Activate) - { - CurrentHideState = HideState.None; - _logger.LogTrace("HideState: User → None (activate)"); - } - - if ( - CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle - || (Plugin.Config.HideWhenNotLoggedIn && !Plugin.ClientState.IsLoggedIn) - ) - { - IsHidden = true; - return; - } - - IsHidden = false; - } - - internal void BeginFrame() - { - DrewThisFrame = false; - } - - internal void FinalizeFrame() - { - if (!DrewThisFrame) - InputFocused = false; - } - - public override unsafe void PreOpenCheck() - { - Flags = - ImGuiWindowFlags.NoScrollbar - | ImGuiWindowFlags.NoScrollWithMouse - | ImGuiWindowFlags.NoFocusOnAppearing; - if (!Plugin.Config.CanMove) - Flags |= ImGuiWindowFlags.NoMove; - - if (!Plugin.Config.CanResize) - Flags |= ImGuiWindowFlags.NoResize; - - if (!Plugin.Config.ShowTitleBar) - Flags |= ImGuiWindowFlags.NoTitleBar; - - // BgAlpha wird auf den Style-WindowBg-Alpha aus HellionStyle.PushGlobal - // multipliziert (HellionStyle pusht eine voll-deckende Theme-Color, der - // tatsächliche transparent-Effekt entsteht über BgAlpha). Wenn der User - // im Dalamud-Pinning-Menü (Hamburger oben rechts) eine eigene - // Window-Deckkraft eingestellt hat, hat dieses Per-Window-Override - // Vorrang über unseren Slider — wir dokumentieren das im HelpMarker. - if (LastViewport == ImGuiHelpers.MainViewport.Handle && !WasDocked) - { - // UI-12: focus-dependent opacity. PreOpenCheck runs before Begin(); - // Window.IsFocused holds last frame's RootAndChildWindows focus, set - // by Dalamud's WindowHost after Begin(). One-frame latency is - // accepted. - BgAlpha = IsFocused ? Plugin.Config.WindowOpacity : Plugin.Config.WindowOpacityInactive; - } - - LastViewport = ImGui.GetWindowViewport().Handle; - WasDocked = ImGui.IsWindowDocked(); - } - - public override bool DrawConditions() - { - FrameTime = Environment.TickCount64; - if (IsHidden) - return false; - - if ( - !Plugin.Config.HideWhenInactive - || (!Plugin.Config.InactivityHideActiveDuringBattle && Plugin.InBattle) - || Activate - ) - { - LastActivityTime = FrameTime; - return true; - } - - var currentTab = Plugin.CurrentTab; // local to avoid calling the getter repeatedly - var lastActivityTime = Plugin - .Config.Tabs.Where(tab => !tab.PopOut && (tab.UnhideOnActivity || tab == currentTab)) - .Select(tab => tab.LastActivity) - .Append(LastActivityTime) - .Max(); - return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout; - } - - public override void PreDraw() - { - if (Plugin.Config.KeepInputFocus && Activate) - ImGui.SetWindowFocus(WindowName); - - // Hellion Chat v1.1.0+ — Theme-Engine ist Source-of-Truth, kein - // zusätzlicher Dalamud-StyleModel-Override mehr pro Window. Plugin.Draw - // pusht das aktive Hellion-Theme global; ChatLogWindow zeichnet sich - // damit konsistent zu Settings/Pop-Out/Wizard. Wer den Upstream-Look - // will, wählt das Built-In-Theme "Chat 2 Klassik" in Settings → Themes. - } - - public override void PostDraw() - { - // Set Activate to false after draw to avoid repeatedly trying to focus - // the text input in a tab with input disabled. The usual way that - // Activate gets disabled is via the text input callback, but that - // doesn't get called if the input is disabled. - if (Plugin.CurrentTab.InputDisabled) - Activate = false; - } - - public override void OnClose() - { - // We force the main log to be always open - IsOpen = true; - } - - // v1.4.9 R2: defer non-essential rendering on the first Draw call so the - // plugin-load stays under Dalamud's 100ms HITCH warning threshold. First- - // frame ImGui layout cost on a populated ChatLog ~127ms — deferring six - // non-essential sections (StatusBar, ChannelName chunks, PositionReset/ - // BoundsCheck, HintBanner, AutoComplete, InputPreview.CalculatePreview) - // shaves ~33ms down to ~94ms. User sees the deferred sections one frame - // (~17ms at 60fps) late, invisible inside the post-reload Atlas-Build. - private bool _firstFrameDone; - - // Set when the user clicks the scroll-to-bottom button; the next - // frame's scroll-snap check forces a jump to the live end. - private bool _scrollToBottomRequested; - - // Cached each frame inside the ##chat2-messages child. True when the - // user has scrolled up enough that the toolbar button should be shown. - private bool _childScrolledUp; - - public override void Draw() - { - DrewThisFrame = true; - try - { - DrawChatLog(); - AddPopOutsToDraw(); - - // v1.4.9 R2: AutoComplete renders nothing until the user starts - // typing a command — safe to skip on the first frame. ~6ms. - if (_firstFrameDone) - DrawAutoComplete(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error drawing Chat Log window"); - if (!NotifiedDrawFailure) - { - Plugin.Notification.AddNotification( - new Dalamud.Interface.ImGuiNotification.Notification - { - Title = "Hellion Chat", - Content = "A drawing error occurred. Check /xllog for details.", - Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning, - InitialDuration = TimeSpan.FromSeconds(20), - } - ); - NotifiedDrawFailure = true; - } - // Prevent recurring draw failures from constantly trying to grab - // input focus, which breaks every other ImGui window. - Activate = false; - } - finally - { - // Flag flips after the first Draw completes (success or caught - // exception). Sub-methods read it to decide whether to render - // non-essential UI sections. - _firstFrameDone = true; - } - } - - private static bool IsChatMode => - Plugin.Config.PreviewPosition is PreviewPosition.Inside or PreviewPosition.Tooltip; - - private unsafe void DrawChatLog() - { - // Position change has applied, so we set it to null again - Position = null; - - var currentSize = ImGui.GetWindowSize(); - var resized = LastWindowSize != currentSize; - LastWindowSize = currentSize; - LastWindowPos = ImGui.GetWindowPos(); - - // v1.4.9 R2: skip the bounds-check chain on the first frame. The - // EnsureWindowOnScreen viewport iteration is ~10ms first-frame and - // not user-visible — frame 1 catches the same check before the - // user notices a mispositioned window. - if (_firstFrameDone) - { - // Manual reset snaps unconditionally; on-load check only fires when the - // stored position has no overlap with any visible viewport. - if (RequestPositionReset) - { - RequestPositionReset = false; - DidOnLoadBoundsCheck = true; - ApplySafeDefaultPosition("manual-reset"); - } - else if (!DidOnLoadBoundsCheck) - { - DidOnLoadBoundsCheck = true; - EnsureWindowOnScreen("on-load"); - } - } - - if (resized) - LastResize.Restart(); - - LastViewport = ImGui.GetWindowViewport().Handle; - WasDocked = ImGui.IsWindowDocked(); - - // v1.4.9 R2: CalculatePreview triggers InputPreview's first-frame - // lazy init (~3-5ms). User-typing-driven, safe to defer one frame. - if (_firstFrameDone && IsChatMode && Plugin.InputPreview.IsDrawable) - Plugin.InputPreview.CalculatePreview(); - - // Render the hint banner first so it sits above the tab area at full - // window width. ImGui accounts for its height automatically. - // v1.4.9 R2: skip on first frame (~3-5ms layout cost). The banner - // is a v0.6.1 migration notice that returns the same result frame 1. - if (_firstFrameDone) - DrawV061HintBannerIfNeeded(); - - if (Plugin.Config.SidebarTabView) - DrawTabSidebar(); - else - DrawTabBar(); - - var activeTab = Plugin.CurrentTab; - - // This tab has a fixed channel, so we force this channel to be always set as current - if (activeTab.Channel is not null) - activeTab.CurrentChannel.SetChannel(activeTab.Channel.Value); - - if ( - Plugin.Config.PreviewPosition is PreviewPosition.Inside - && Plugin.InputPreview.IsDrawable - ) - Plugin.InputPreview.DrawPreview(); - - using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) - { - DrawChannelName(activeTab); - } - - // inputColour computed up front so the channel selector button can share it. - var inputType = activeTab.CurrentChannel.UseTempChannel - ? activeTab.CurrentChannel.TempChannel.ToChatType() - : activeTab.CurrentChannel.Channel.ToChatType(); - var isCommand = Chat.Trim().StartsWith('/'); - if (isCommand) - { - var command = Chat.Split(' ')[0]; - if (TextCommandChannels.TryGetValue(command, out var channel)) - inputType = channel; - - if (!IsValidCommand(command)) - inputType = ChatType.Error; - } - - var inputColour = Plugin.Config.ChatColours.TryGetValue(inputType, out var inputCol) - ? inputCol - : inputType.DefaultColor(); - - if (!isCommand && Plugin.ExtraChat.ChannelOverride is var (_, overrideColour)) - inputColour = overrideColour; - - if ( - isCommand - && Plugin.ExtraChat.ChannelCommandColours.TryGetValue( - Chat.Split(' ')[0], - out var ecColour - ) - ) - inputColour = ecColour; - - // Symbol-picker trigger sits left of the channel indicator. ImRaii.Popup - // inside DrawAndConsume pins to the last rendered item, so the call MUST - // run immediately after this IconButton — placing it after the channel - // picker below would pin the popup under the wrong widget. - if (Plugin.Config.SymbolPickerEnabled) - { - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.Smile, - "symbol-picker-trigger", - "Insert symbol or FFXIV icon" - ) - ) - { - _symbolPicker.OpenPopup(); - } - } - // DrawAndConsume runs unconditionally; with the button hidden the popup - // can't open, so the call is a no-op. Splice path stays outside the - // guard for the same reason. - var insertedSymbol = _symbolPicker.DrawAndConsume(); - if (insertedSymbol is not null) - { - // Same cursor-aware splice idiom as the AutoComplete commit path at - // ChatLogWindow.cs:2487-2493. Clamp because CursorPos can drift if - // the user mutates Chat while the popup is open. - var pos = Math.Clamp(CursorPos, 0, Chat.Length); - Chat = Chat[..pos] + insertedSymbol + Chat[pos..]; - Activate = true; - ActivatePos = pos + insertedSymbol.Length; - } - if (Plugin.Config.SymbolPickerEnabled) - ImGui.SameLine(); - - var beforeIcon = ImGui.GetCursorPos(); - - var tintSelector = Plugin.Config.ColorSelectedInputChannelButton && inputColour.HasValue; - var selectorAbgr = tintSelector ? ColourUtil.RgbaToAbgr(inputColour!.Value) : 0u; - - using (ImRaii.PushColor(ImGuiCol.Button, selectorAbgr, tintSelector)) - using ( - ImRaii.PushColor( - ImGuiCol.ButtonHovered, - ColourUtil.AdjustBrightness(selectorAbgr, 1.15f), - tintSelector - ) - ) - using ( - ImRaii.PushColor( - ImGuiCol.ButtonActive, - ColourUtil.AdjustBrightness(selectorAbgr, 0.85f), - tintSelector - ) - ) - { - if (ImGuiUtil.IconButton(FontAwesomeIcon.Comment) && activeTab.Channel is null) - ImGui.OpenPopup(ChatChannelPicker); - } - - if (activeTab.Channel is not null && ImGui.IsItemHovered()) - ImGuiUtil.Tooltip(Language.ChatLog_SwitcherDisabled); - - using (var popup = ImRaii.Popup(ChatChannelPicker)) - { - if (popup) - { - var channels = GetValidChannels(); - foreach (var (name, channel) in channels) - if (ImGui.Selectable(name)) - SetChannel(channel); - } - } - - ImGui.SameLine(); - var afterIcon = ImGui.GetCursorPos(); - - var buttonWidth = afterIcon.X - beforeIcon.X; - var showNovice = Plugin.Config.ShowNoviceNetwork && GameFunctions.GameFunctions.IsMentor(); - var buttonsRight = (showNovice ? 1 : 0) + (Plugin.Config.ShowHideButton ? 1 : 0); - // Right-side buttons: quick-picker palette + cog (always present) - // plus the optional hide / novice buttons. Each slot costs the - // measured button width AND one ItemSpacing for the SameLine gap - // in front of it -- leaving the spacing term out overflows the - // header row by one gap per button (v1.5.4 quick-picker fix). - var rightButtonCount = 2 + buttonsRight; - var inputWidth = - ImGui.GetContentRegionAvail().X - - rightButtonCount * (buttonWidth + ImGui.GetStyle().ItemSpacing.X); - - var normalColor = ImGui.GetColorU32(ImGuiCol.Text); - var push = inputColour != null; - using ( - ImRaii.PushColor( - ImGuiCol.Text, - push ? ColourUtil.RgbaToAbgr(inputColour!.Value) : 0, - push - ) - ) - { - var isChatEnabled = activeTab is { InputDisabled: false }; - if (isChatEnabled && (Activate || FocusedPreview)) - { - FocusedPreview = false; - ImGui.SetKeyboardFocusHere(); - } - - var chatCopy = Chat; - using (ImRaii.Disabled(!isChatEnabled)) - { - var flags = - InputFlags - | (!isChatEnabled ? ImGuiInputTextFlags.ReadOnly : ImGuiInputTextFlags.None); - ImGui.SetNextItemWidth(inputWidth); - ImGui.InputTextWithHint( - "##chat2-input", - isChatEnabled ? "" : Language.ChatLog_DisabledInput, - ref Chat, - 500, - flags, - Callback - ); - } - var inputActive = ImGui.IsItemActive(); - InputFocused = isChatEnabled && inputActive; - - var tooltipDraw = - Plugin.Config.PreviewPosition is PreviewPosition.Tooltip - && Plugin.InputPreview.IsDrawable; - if (tooltipDraw && ImGui.IsItemHovered()) - { - ImGui.SetNextWindowSize(new Vector2(500 * ImGuiHelpers.GlobalScale, -1)); - using var tooltip = ImRaii.Tooltip(); - Plugin.InputPreview.DrawPreview(); - } - - if (ImGui.IsItemDeactivated()) - { - if (ImGui.IsKeyDown(ImGuiKey.Escape)) - { - Chat = chatCopy; - - // UI-11: Escape cancels the input — drop any pending - // disclosure arming so the warning does not linger. - _disclosureArmedBufferMain = null; - - if (activeTab.CurrentChannel.UseTempChannel) - { - activeTab.CurrentChannel.ResetTempChannel(); - SetChannel(activeTab.CurrentChannel.Channel); - } - } - - if (ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter)) - { - if ( - Plugin.Config.NotifyPluginDisclosure - && Chat != _disclosureArmedBufferMain - && PluginDisclosureScanner.ContainsPrivateUseGlyph(Chat) - ) - { - // First send attempt on this exact buffer: arm and hold. - // The warning renders below the input. - _disclosureArmedBufferMain = Chat; - } - else - { - _disclosureArmedBufferMain = null; - Plugin.CommandHelpWindow.IsOpen = false; - SendChatBox(activeTab); - - if (activeTab.CurrentChannel.UseTempChannel) - { - activeTab.CurrentChannel.ResetTempChannel(); - SetChannel(activeTab.CurrentChannel.Channel); - } - } - } - } - - // UI-11: disclosure warning for the main-window input, mirrors the - // ChatInputBar path. Visible only while the armed buffer is held - // unchanged; editing the buffer clears the condition. - if ( - Plugin.Config.NotifyPluginDisclosure - && _disclosureArmedBufferMain is not null - && Chat == _disclosureArmedBufferMain - ) - { - ImGui.TextColored( - ImGuiColors.DalamudYellow, - HellionStrings.ChatInput_PluginDisclosure_Warning - ); - } - - // Process keybinds that have modifiers while the chat is focused. - if (inputActive) - { - Plugin.Functions.KeybindManager.HandleKeybinds(KeyboardSource.ImGui, true, true); - LastActivityTime = FrameTime; - } - - // Only trigger unfocused if we are currently not calling the auto complete - if (!Activate && !inputActive && AutoCompleteInfo == null) - { - if (Plugin.Config.PlaySounds && !PlayedClosingSound) - { - PlayedClosingSound = true; - UIGlobals.PlaySoundEffect(ChatCloseSfx); - } - - if (activeTab.CurrentChannel.UseTempChannel) - { - activeTab.CurrentChannel.ResetTempChannel(); - SetChannel(Plugin.CurrentTab.CurrentChannel.Channel); - } - } - - using (var context = ImRaii.ContextPopupItem("ChatInputContext")) - { - if (context) - { - using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, normalColor); - if (ImGui.Selectable(Language.ChatLog_HideChat)) - UserHide(); - - // Insert game text-macro tokens. The game expands / at - // send time, so inserting literal token text is enough. Each entry is - // disabled when its precondition is unmet (no map flag, no linked item) - // so the inserted token cannot expand to nothing. - unsafe - { - // Null-check before deref: pointers can be null during zone transitions. - var agentMap = AgentMap.Instance(); - var flagSet = agentMap != null && agentMap->FlagMarkerCount > 0; - using (ImRaii.Disabled(!flagSet)) - { - if (ImGui.Selectable(HellionStrings.ChatLog_Insert_MapFlag)) - { - Chat += ""; - Activate = true; - ActivatePos = Chat.Length; - } - } - - var agentChat = AgentChatLog.Instance(); - var itemSet = agentChat != null && agentChat->LinkedItem.ItemId != 0; - using (ImRaii.Disabled(!itemSet)) - { - if (ImGui.Selectable(HellionStrings.ChatLog_Insert_ItemLink)) - { - Chat += ""; - Activate = true; - ActivatePos = Chat.Length; - } - } - } - } - } - } - - ImGui.SameLine(); - - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.Palette, - tooltip: HellionStrings.Settings_QuickPicker_Tooltip, - width: (int)buttonWidth - ) - ) - ImGui.OpenPopup("##hellion-quick-picker"); - - DrawQuickPickerPopup(); - - ImGui.SameLine(); - - if (ImGuiUtil.IconButton(FontAwesomeIcon.Cog, width: (int)buttonWidth)) - Plugin.SettingsWindow.Toggle(); - - if (Plugin.Config.ShowHideButton) - { - ImGui.SameLine(); - if (ImGuiUtil.IconButton(FontAwesomeIcon.EyeSlash, width: (int)buttonWidth)) - UserHide(); - } - - if (ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows)) - LastActivityTime = FrameTime; - - if (showNovice) - { - ImGui.SameLine(); - - if (ImGuiUtil.IconButton(FontAwesomeIcon.Leaf)) - GameFunctions.GameFunctions.ClickNoviceNetworkButton(); - } - - // v1.2.0 — Bottom-Status-Bar. Letzter Render-Step in DrawChatLog, - // damit alle Zeilen-Operationen davor keine Layout-Sprünge auslösen. - // v1.4.9 R2: skip on the first frame; ~12ms of first-frame layout - // cost. User sees the StatusBar 1 frame (~17ms at 60fps) later - // which is hidden inside the post-reload Atlas-Build window. - if (_firstFrameDone) - Plugin.StatusBar.Draw(Plugin); - } - - internal Dictionary GetValidChannels() - { - var channels = new Dictionary(); - foreach (var channel in Enum.GetValues()) - { - if (!channel.IsValid()) - continue; - - var name = - Sheets - .LogFilterSheet.FirstOrNull(row => row.LogKind == (byte)channel.ToChatType()) - ?.Name.ToString() - ?? channel.ToChatType().Name(); - if (channel.IsLinkshell()) - { - var lsName = Plugin.Functions.Chat.GetLinkshellName(channel.LinkshellIndex()); - if (string.IsNullOrWhiteSpace(lsName)) - continue; - - name += $": {lsName}"; - } - - if (channel.IsCrossLinkshell()) - { - var lsName = Plugin.Functions.Chat.GetCrossLinkshellName(channel.LinkshellIndex()); - if (string.IsNullOrWhiteSpace(lsName)) - continue; - - name += $": {lsName}"; - } - - // Check if the linkshell with this index is registered in - // the ExtraChat plugin by seeing if the command is - // registered. The command gets registered only if a - // linkshell is assigned (and even gets unassigned if the - // index changes!). - if (channel.IsExtraChatLinkshell()) - if (!Plugin.CommandManager.Commands.ContainsKey(channel.Prefix())) - continue; - - channels.Add(name, channel); - } - - return channels; - } - - private void DrawChannelName(Tab activeTab) - { - // v1.4.9 R2: plain-text fallback on the first frame. ReadChannelName - // builds SeString chunks and DrawChunks runs SeString-Renderer layout - // — together ~18ms first-frame. Frame 1 renders the real chunks; the - // user sees the tab name for ~17ms during the post-reload window. - if (!_firstFrameDone) - { - ImGui.TextUnformatted(activeTab.Name); - return; - } - - var currentChannel = ReadChannelName(activeTab); - if (!currentChannel.SequenceEqual(PreviousChannel)) - PreviousChannel = currentChannel; - - DrawChunks(currentChannel); - } - - private Chunk[] ReadChannelName(Tab activeTab) - { - Chunk[] channelNameChunks; - // Check the temp channel before others - if (activeTab.CurrentChannel.UseTempChannel) - { - if ( - activeTab.CurrentChannel.TempTellTarget != null - && activeTab.CurrentChannel.TempTellTarget.IsSet() - ) - { - channelNameChunks = GenerateTellTargetName(activeTab.CurrentChannel.TempTellTarget); - } - else - { - string name; - if (activeTab.CurrentChannel.TempChannel.IsLinkshell()) - { - var idx = - (uint)activeTab.CurrentChannel.TempChannel - (uint)InputChannel.Linkshell1; - var lsName = Plugin.Functions.Chat.GetLinkshellName(idx); - name = $"LS #{idx + 1}: {lsName}"; - } - else if (activeTab.CurrentChannel.TempChannel.IsCrossLinkshell()) - { - var idx = - (uint)activeTab.CurrentChannel.TempChannel - - (uint)InputChannel.CrossLinkshell1; - var cwlsName = Plugin.Functions.Chat.GetCrossLinkshellName(idx); - name = $"CWLS [{idx + 1}]: {cwlsName}"; - } - else - { - name = activeTab.CurrentChannel.TempChannel.ToChatType().Name(); - } - - channelNameChunks = [new TextChunk(ChunkSource.None, null, name)]; - } - } - else if (activeTab.CurrentChannel.TellTarget?.IsSet() == true) - { - channelNameChunks = GenerateTellTargetName(activeTab.CurrentChannel.TellTarget); - } - else if (activeTab is { Channel: { } channel }) - { - if (channel == InputChannel.Tell && activeTab.TellTarget.IsSet()) - { - channelNameChunks = GenerateTellTargetName(activeTab.TellTarget); - } - else - { - // ExtraChat channel names aren't available over IPC by index, - // so we skip the name lookup and show the short form instead. - channelNameChunks = - [ - new TextChunk( - ChunkSource.None, - null, - channel.IsExtraChatLinkshell() - ? $"ECLS [{channel.LinkshellIndex() + 1}]" - : channel.ToChatType().Name() - ), - ]; - } - } - else if (Plugin.ExtraChat.ChannelOverride is var (overrideName, _)) - { - // If the current channel is not an ExtraChat Linkshell add a warning for the user - var warning = activeTab.CurrentChannel.Channel.IsExtraChatLinkshell() - ? "" - : $" (Warning: {activeTab.CurrentChannel.Channel.ToChatType().Name()})"; - - channelNameChunks = [new TextChunk(ChunkSource.None, null, $"{overrideName}{warning}")]; - } - else if ( - ScreenshotMode - && activeTab.CurrentChannel.Channel is InputChannel.Tell - && activeTab.CurrentChannel.TellTarget != null - ) - { - if ( - !string.IsNullOrWhiteSpace(activeTab.CurrentChannel.TellTarget.Name) - && activeTab.CurrentChannel.TellTarget.World != 0 - ) - { - // Note: don't use HidePlayerInString here because abbreviation settings do not affect this. - var playerName = HashPlayer( - activeTab.CurrentChannel.TellTarget.Name, - activeTab.CurrentChannel.TellTarget.World - ); - var world = Sheets.WorldSheet.TryGetRow( - activeTab.CurrentChannel.TellTarget.World, - out var worldRow - ) - ? worldRow.Name.ExtractText() - : "???"; - - channelNameChunks = - [ - new TextChunk(ChunkSource.None, null, "Tell "), - new TextChunk(ChunkSource.None, null, playerName), - new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld), - new TextChunk(ChunkSource.None, null, world), - ]; - } - else - { - // We still need to censor the name if we couldn't read valid data. - channelNameChunks = [new TextChunk(ChunkSource.None, null, "Tell")]; - } - } - else - { - channelNameChunks = - activeTab.CurrentChannel.Name.Count > 0 - ? activeTab.CurrentChannel.Name.ToArray() - : - [ - new TextChunk( - ChunkSource.None, - null, - activeTab.CurrentChannel.Channel.ToChatType().Name() - ), - ]; - } - - return channelNameChunks; - } - - internal void SetChannel(InputChannel? channel) - { - channel ??= InputChannel.Say; - if (channel != InputChannel.Tell) - { - Plugin.CurrentTab.CurrentChannel.TellTarget = null; - Plugin.CurrentTab.CurrentChannel.TempTellTarget = null; - } - - // ExtraChat linkshell channel switch: call the prefix command through the - // game chat because ExtraChat only registers stub handlers in Dalamud. - if (channel.Value.IsExtraChatLinkshell()) - { - // Check that the command is registered in Dalamud so the game code - // never sees the command itself. - if (!Plugin.CommandManager.Commands.ContainsKey(channel.Value.Prefix())) - return; - - // Send the command through the game chat. We can't call - // ICommandManager.ProcessCommand() here because ExtraChat only - // registers stub handlers and actually processes its commands in a - // SendMessage detour. - var bytes = Encoding.UTF8.GetBytes(channel.Value.Prefix()); - ChatBox.SendMessageUnsafe(bytes); - - Plugin.CurrentTab.CurrentChannel.Channel = channel.Value; - return; - } - - var target = - Plugin.CurrentTab.CurrentChannel.TempTellTarget - ?? Plugin.CurrentTab.CurrentChannel.TellTarget; - Plugin.Functions.Chat.SetChannel(channel.Value, target); - } - - private Chunk[] GenerateTellTargetName(TellTarget tellTarget) - { - var playerName = tellTarget.Name; - if (ScreenshotMode) - // Note: don't use HidePlayerInString here because - // abbreviation settings do not affect this. - playerName = HashPlayer(tellTarget.Name, tellTarget.World); - - var world = Sheets.WorldSheet.TryGetRow(tellTarget.World, out var worldRow) - ? worldRow.Name.ToString() - : "???"; - - return - [ - new TextChunk(ChunkSource.None, null, "Tell "), - new TextChunk(ChunkSource.None, null, playerName), - new IconChunk(ChunkSource.None, null, BitmapFontIcon.CrossWorld), - new TextChunk(ChunkSource.None, null, world), - ]; - } - - // Pop-out windows route submission here. The main Chat buffer is briefly - // used as a vehicle for SendChatBox and restored afterwards. - internal void SendChatBoxFromExternal(Tab tab, string text) - { - var saved = Chat; - Chat = text; - SendChatBox(tab); - Chat = saved; - } - - internal void SendChatBox(Tab activeTab) - { - if (!string.IsNullOrWhiteSpace(Chat)) - { - var trimmed = Chat.Trim(); - AddBacklog(trimmed); - InputBacklogIdx = -1; - - if (HasTranslationCommand(trimmed)) - { - activeTab.CurrentChannel.ResetTempChannel(); - Chat = string.Empty; - return; - } - - if (TellSpecial) - { - var tellBytes = Encoding.UTF8.GetBytes(trimmed); - AutoTranslate.ReplaceWithPayload(ref tellBytes); - - Plugin.Functions.Chat.SendTellUsingCommandInner(tellBytes); - TellSpecial = false; - - activeTab.CurrentChannel.ResetTempChannel(); - Chat = string.Empty; - return; - } - - if (!trimmed.StartsWith('/')) - { - var target = activeTab.TellTarget.IsSet() - ? activeTab.TellTarget - : activeTab.CurrentChannel.TempTellTarget - ?? activeTab.CurrentChannel.TellTarget; - if (target != null) - { - // ContentId 0: can't send directly, so format as /tell and let the game handle it. - if (target.ContentId == 0) - { - trimmed = $"/tell {target.ToTargetString()} {trimmed}"; - var tellBytes = Encoding.UTF8.GetBytes(trimmed); - AutoTranslate.ReplaceWithPayload(ref tellBytes); - - ChatBox.SendMessageUnsafe(tellBytes); - - activeTab.CurrentChannel.ResetTempChannel(); - Chat = string.Empty; - return; - } - - var reason = target.Reason; - var world = Sheets.WorldSheet.GetRow(target.World); - if (world is { IsPublic: true }) - { - if ( - reason == TellReason.Reply - && GameFunctions - .GameFunctions.GetFriends() - .Any(friend => friend.ContentId == target.ContentId) - ) - reason = TellReason.Friend; - - var tellBytes = Encoding.UTF8.GetBytes(trimmed); - AutoTranslate.ReplaceWithPayload(ref tellBytes); - - Plugin.Functions.Chat.SendTell( - reason, - target.ContentId, - target.Name, - (ushort)world.RowId, - tellBytes, - trimmed - ); - } - - activeTab.CurrentChannel.ResetTempChannel(); - Chat = string.Empty; - return; - } - - if (activeTab.CurrentChannel.UseTempChannel) - trimmed = $"{activeTab.CurrentChannel.TempChannel.Prefix()} {trimmed}"; - else - trimmed = $"{activeTab.CurrentChannel.Channel.Prefix()} {trimmed}"; - } - - var bytes = Encoding.UTF8.GetBytes(trimmed); - AutoTranslate.ReplaceWithPayload(ref bytes); - - ChatBox.SendMessageUnsafe(bytes); - } - - activeTab.CurrentChannel.ResetTempChannel(); - Chat = string.Empty; - } - - private bool HasTranslationCommand(string trimmed) - { - var messageBytes = Encoding.UTF8.GetBytes(trimmed); - if (AutoTranslate.StartsWithCommand(ref messageBytes)) - { - ChatBox.SendMessageUnsafe(messageBytes); - return true; - } - - return false; - } - - internal void UserHide() - { - CurrentHideState = HideState.User; - } - - internal void DrawMessageLog( - Tab tab, - PayloadHandler handler, - float childHeight, - bool switchedTab, - bool updateScrollState = true - ) - { - using (var child = ImRaii.Child("##chat2-messages", new Vector2(-1, childHeight))) - { - if (child.Success) - { - if (tab.DisplayTimestamp && Plugin.Config.PrettierTimestamps) - DrawLogTableStyle(tab, handler, switchedTab); - else - DrawLogNormalStyle(tab, handler, switchedTab); - - // Cached for the header toolbar's scroll-to-bottom button, which is - // drawn one frame later. GetScrollMaxY / GetScrollY here refer to - // the child's scroll context. Pop-out windows pass updateScrollState: - // false so they do not overwrite the main window's cached state. - if (updateScrollState) - _childScrolledUp = ImGui.GetScrollMaxY() - ImGui.GetScrollY() > 1f; - } - else - { - if (updateScrollState) - _childScrolledUp = false; - } - } - } - - private void DrawLogNormalStyle(Tab tab, PayloadHandler handler, bool switchedTab) - { - using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) - DrawMessages(tab, handler, false); - - if (switchedTab || _scrollToBottomRequested || ImGui.GetScrollY() >= ImGui.GetScrollMaxY()) - ImGui.SetScrollHereY(1f); - _scrollToBottomRequested = false; - - handler.Draw(); - } - - private void DrawLogTableStyle(Tab tab, PayloadHandler handler, bool switchedTab) - { - var compact = Plugin.Config.MoreCompactPretty; - var oldItemSpacing = ImGui.GetStyle().ItemSpacing; - var oldCellPadding = ImGui.GetStyle().CellPadding; - - using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) - using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, oldCellPadding with { Y = 0 }, compact)) - { - using var table = ImRaii.Table("timestamp-table", 2, ImGuiTableFlags.PreciseWidths); - if (!table.Success) - return; - - ImGui.TableSetupColumn("timestamps", ImGuiTableColumnFlags.WidthFixed); - ImGui.TableSetupColumn("messages", ImGuiTableColumnFlags.WidthStretch); - - DrawMessages(tab, handler, true, compact, oldCellPadding.Y); - - using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, oldItemSpacing)) - using (ImRaii.PushStyle(ImGuiStyleVar.CellPadding, oldCellPadding)) - { - // Custom styles can have cellPadding that go above 4, which GetScrollY isn't respecting - var cellPaddingOffset = - !compact && oldCellPadding.Y > 4f ? oldCellPadding.Y - 4f : 0f; - if ( - switchedTab - || _scrollToBottomRequested - || ImGui.GetScrollY() + cellPaddingOffset >= ImGui.GetScrollMaxY() - ) - ImGui.SetScrollHereY(1f); - _scrollToBottomRequested = false; - - handler.Draw(); - } - } - } - - private void DrawMessages( - Tab tab, - PayloadHandler handler, - bool isTable, - bool moreCompact = false, - float oldCellPaddingY = 0 - ) - { - try - { - // This may produce ApplicationException which is catched below. - using var messages = tab.Messages.GetReadOnly(3); - - var reset = false; - if (LastResize is { IsRunning: true, Elapsed.TotalSeconds: > 0.25 }) - { - LastResize.Stop(); - LastResize.Reset(); - reset = true; - } - - var lastPosY = ImGui.GetCursorPosY(); - var lastTimestamp = string.Empty; - int? lastMessageHash = null; - var sameCount = 0; - - var maxLines = Plugin.Config.MaxLinesToRender; - var startLine = messages.Count > maxLines ? messages.Count - maxLines : 0; - - // Card-mode pre-loop: theme/drawList/winLeft/winRight are - // invariant per DrawMessages call. borderColorAbgr used to be - // hoisted here too, but PM-3d (v1.5.4) modulates it by - // tab._cardHoverAlpha per row, so it moves into the AddLine - // call below. anyCardHovered aggregates the row-hover state - // across all card-rows; the lerp runs once at the loop end so - // the next frame paints with the updated alpha. - var theme = Plugin.ThemeRegistry.Active; - var drawList = ImGui.GetWindowDrawList(); - var winLeft = ImGui.GetWindowPos().X; - var winRight = winLeft + ImGui.GetWindowSize().X; - var baseBorderRgba = (theme.Colors.Border & 0xFFFFFF00u) | 0x33u; - var anyCardHovered = false; - - for (var i = startLine; i < messages.Count; i++) - { - var message = messages[i]; - if (reset) - { - message.Height[tab.Identifier] = null; - message.IsVisible[tab.Identifier] = false; - } - - if (Plugin.Config.CollapseDuplicateMessages) - { - var messageHash = message.Hash; - var same = lastMessageHash == messageHash; - if (same) - { - sameCount += 1; - message.IsVisible[tab.Identifier] = false; - if (i != messages.Count - 1) - continue; - } - - if (sameCount > 0) - { - ImGui.SameLine(); - DrawChunks( - [ - new TextChunk(ChunkSource.None, null, $" ({sameCount + 1}x)") - { - FallbackColour = ChatType.System, - Italic = true, - }, - ], - true, - handler, - ImGui.GetContentRegionAvail().X - ); - sameCount = 0; - } - - lastMessageHash = messageHash; - if (same && i == messages.Count - 1) - continue; - } - - // go to next row - if (isTable) - ImGui.TableNextColumn(); - - // Set the height of the previous message. `lastPosY` is set to - // the top of the previous message, and the current cursor is at - // the top of the current message. - if (i > 0) - { - var prevMessage = messages[i - 1]; - prevMessage.Height.TryGetValue(tab.Identifier, out var prevHeight); - if ( - prevHeight == null - || ( - prevMessage.IsVisible.TryGetValue(tab.Identifier, out var prevVisible) - && prevVisible - ) - ) - { - var newHeight = ImGui.GetCursorPosY() - lastPosY; - - // Remove the padding from the bottom of the previous row and the top of the current row. - if (isTable && !moreCompact) - newHeight -= oldCellPaddingY * 2; - - if (newHeight != 0) - prevMessage.Height[tab.Identifier] = newHeight; - } - } - lastPosY = ImGui.GetCursorPosY(); - - // message has rendered once - // message isn't visible, so render dummy - message.Height.TryGetValue(tab.Identifier, out var height); - message.IsVisible.TryGetValue(tab.Identifier, out var visible); - if (height != null && !visible) - { - var beforeDummy = ImGui.GetCursorPos(); - - // skip to the message column for vis test - if (isTable) - ImGui.TableNextColumn(); - - ImGui.Dummy(new Vector2(10f, height.Value)); - - var nowVisible = ImGui.IsItemVisible(); - if (!nowVisible) - continue; - - if (isTable) - ImGui.TableSetColumnIndex(0); - - ImGui.SetCursorPos(beforeDummy); - message.IsVisible[tab.Identifier] = nowVisible; - } - - if (tab.DisplayTimestamp) - { - var localTime = message.Date.ToLocalTime(); - // Force the format explicitly per setting. Relying on the - // current culture meant a German system locale always - // produced 24h regardless of the toggle, so the checkbox - // looked dead. - var timestamp = Plugin.Config.Use24HourClock - ? localTime.ToString("HH:mm", CultureInfo.InvariantCulture) - : localTime.ToString("h:mm tt", CultureInfo.InvariantCulture); - if (isTable) - { - if (!Plugin.Config.HideSameTimestamps || timestamp != lastTimestamp) - { - lastTimestamp = timestamp; - ImGui.TextUnformatted(timestamp); - - // We use an IsItemHovered() check here instead of - // just calling Tooltip() to avoid computing the - // tooltip string for all visible items on every - // frame. - if (ImGui.IsItemHovered()) - ImGuiUtil.Tooltip(localTime.ToString("F")); - } - else - { - // Avoids rendering issues caused by emojis in - // message content. - ImGui.TextUnformatted(""); - } - } - else - { - DrawChunk( - new TextChunk(ChunkSource.None, null, $"[{timestamp}] ") - { - Foreground = 0xFFFFFFFF, - } - ); - ImGui.SameLine(); - } - } - - if (isTable) - ImGui.TableNextColumn(); - - var lineWidth = ImGui.GetContentRegionAvail().X; - - // v1.2.0 card mode: sender on its own line in channel color, then body, - // then a subtle border as a card separator. - // Compact mode: sender + space + content on one line via SameLine. - var useCard = !Plugin.Config.UseCompactDensity; - if (useCard) - { - var rowStartY = ImGui.GetCursorScreenPos().Y; - - if (message.Sender.Count > 0) - { - var senderColor = - Plugin.Functions.Chat.GetChannelColor(message.Code.Type) - ?? theme.Colors.TextPrimary; - using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(senderColor))) - { - DrawChunks(message.Sender, true, handler, lineWidth); - } - // No SameLine — body renders on its own line. - } - - // We need to draw something otherwise the item visibility check below won't work. - if (message.Content.Count == 0) - DrawChunks( - [new TextChunk(ChunkSource.Content, null, " ")], - true, - handler, - lineWidth - ); - else - DrawChunks(message.Content, true, handler, lineWidth); - - // Border bottom as card separator. Base alpha 0x33; - // PM-3d lifts it by up to ~+0x70 while any row in this - // tab is hovered. _cardHoverAlpha lerps at the loop - // end, so the one-frame lag is invisible at 10f speed. - { - var rowEndY = ImGui.GetCursorScreenPos().Y; - var hoverBoost = 0.45f * tab._cardHoverAlpha; - var alphaByte = (uint) - Math.Clamp((int)(0x33u + hoverBoost * 255f), 0x33, 0xCC); - var borderColorAbgr = ColourUtil.RgbaToAbgr( - (baseBorderRgba & 0xFFFFFF00u) | alphaByte - ); - drawList.AddLine( - new Vector2(winLeft + 4, rowEndY - 1), - new Vector2(winRight - 4, rowEndY - 1), - borderColorAbgr, - 1f - ); - ImGui.Dummy(new Vector2(0, 2)); - - // Whole-row hover test. IsItemHovered would only see - // the 2px Dummy above, so hit-test the row rect from - // its start Y down to the separator line instead. - if ( - ImGui.IsMouseHoveringRect( - new Vector2(winLeft, rowStartY), - new Vector2(winRight, rowEndY) - ) - ) - anyCardHovered = true; - } - } - else - { - if (message.Sender.Count > 0) - { - DrawChunks(message.Sender, true, handler, lineWidth); - ImGui.SameLine(); - } - - // We need to draw something otherwise the item visibility check below won't work. - if (message.Content.Count == 0) - DrawChunks( - [new TextChunk(ChunkSource.Content, null, " ")], - true, - handler, - lineWidth - ); - else - DrawChunks(message.Content, true, handler, lineWidth); - } - - message.IsVisible[tab.Identifier] = ImGui.IsItemVisible(); - } - - // PM-3d: update the per-tab card-hover lerp once per - // DrawMessages call. ReduceMotion snaps to the target; - // otherwise the border alpha eases toward it over a few - // frames the next time the rows paint. - var cardTarget = anyCardHovered ? 1f : 0f; - tab._cardHoverAlpha = Plugin.Config.ReduceMotion - ? cardTarget - : FrameLerp.Smooth( - tab._cardHoverAlpha, - cardTarget, - speed: 10f, - deltaTime: ImGui.GetIO().DeltaTime - ); - } - catch (ApplicationException) - { - // We couldn't get a reader lock on messages within 3ms, so - // don't draw anything (and don't log a warning either). - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Error drawing chat log"); - } - } - - private void DrawTabBar() - { - using var tabBar = ImRaii.TabBar("##chat2-tabs"); - if (!tabBar.Success) - return; - - var previousTab = Plugin.CurrentTab; - for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++) - { - var tab = Plugin.Config.Tabs[tabI]; - if (tab.PopOut) - continue; - - var unread = - tabI == Plugin.LastTab || tab.UnreadMode == UnreadMode.None || tab.Unread == 0 - ? "" - : $" ({tab.Unread})"; - var flags = ImGuiTabItemFlags.None; - if (Plugin.WantedTab == tabI) - flags |= ImGuiTabItemFlags.SetSelected; - - using var tabItem = ImRaii.TabItem($"{tab.Name}{unread}###log-tab-{tabI}", flags); - DrawTabContextMenu(tab, tabI); - - if (!tabItem.Success) - continue; - - // Active-tab underline pill (2px accent). No native ImGui underline API, - // so we use a direct DrawList pass. Pill height scales with GlobalScale - // and all coordinates round to physical pixels so the line stays crisp - // on 125/150% DPI setups instead of bleeding into a sub-pixel blur. - { - var theme = Plugin.ThemeRegistry.Active; - var min = ImGui.GetItemRectMin(); - var max = ImGui.GetItemRectMax(); - var pillHeight = MathF.Max(1f, MathF.Round(2f * ImGuiHelpers.GlobalScale)); - var yBottom = MathF.Round(max.Y); - var yTop = yBottom - pillHeight; - ImGui - .GetWindowDrawList() - .AddRectFilled( - new Vector2(MathF.Round(min.X), yTop), - new Vector2(MathF.Round(max.X), yBottom), - ColourUtil.RgbaToAbgr(theme.Colors.Accent) - ); - } - - var hasTabSwitched = Plugin.LastTab != tabI; - Plugin.LastTab = tabI; - - if (hasTabSwitched) - TabSwitched(tab, previousTab); - - tab.Unread = 0; - DrawChatHeaderToolbar(tab); - DrawMessageLog(tab, PayloadHandler, GetRemainingHeightForMessageLog(), hasTabSwitched); - } - - Plugin.WantedTab = null; - } - - // Sidebar render order: persistent tabs in their original Plugin.Config.Tabs - // position, then pinned TempTabs, then unpinned TempTabs. Returns indices - // into Plugin.Config.Tabs so tabI in the loop body still mirrors the real - // list position (LastTab / WantedTab stay consistent). - private static List BuildSidebarRenderOrder() - { - var tabs = Plugin.Config.Tabs; - var persistent = new List(tabs.Count); - var pinned = new List(); - var unpinned = new List(); - for (var i = 0; i < tabs.Count; i++) - { - if (TabLifecycleHelpers.IsInPinnedPool(tabs[i])) - pinned.Add(i); - else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i])) - unpinned.Add(i); - else - persistent.Add(i); - } - persistent.AddRange(pinned); - persistent.AddRange(unpinned); - return persistent; - } - - private void DrawTabSidebar() - { - var currentTab = -1; - // Sidebar fixed at 44px, no resize. - using var tabTable = ImRaii.Table( - "tabs-table", - 2, - ImGuiTableFlags.BordersInnerV | ImGuiTableFlags.SizingFixedFit - ); - if (!tabTable.Success) - return; - - var sidebarWidth = Math.Clamp(Plugin.Config.SidebarWidth, 44, 160); - ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, sidebarWidth); - ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 1); - - ImGui.TableNextColumn(); - - var hasTabSwitched = false; - var childHeight = GetRemainingHeightForMessageLog(); - // Sidebar child without ChildBg tint to avoid a colored block above the - // header toolbar area. Vertical separation is handled by BordersInnerV. - using (ImRaii.PushColor(ImGuiCol.ChildBg, 0u)) - using (var child = ImRaii.Child("##chat2-tab-sidebar", new Vector2(-1, childHeight))) - { - if (child) - { - // Top padding mirrors the HeaderToolbar height so sidebar buttons - // align with the message log start. - ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing())); - - var previousTab = Plugin.CurrentTab; - // Render order: persistent → pinned TempTabs → unpinned TempTabs. - // Underlying Plugin.Config.Tabs order is untouched (tabI mirrors - // the real list index), only the display sequence groups by - // section so each section can carry its own divider header. - var renderOrder = BuildSidebarRenderOrder(); - var pinnedHeaderRendered = false; - var tempTabHeaderRendered = false; - var pinnedCount = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool); - var unpinnedTempCount = Plugin.Config.Tabs.Count( - TabLifecycleHelpers.IsInUnpinnedPool - ); - - foreach (var tabI in renderOrder) - { - var tab = Plugin.Config.Tabs[tabI]; - if (tab.PopOut) - continue; - - if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered) - { - ImGui.Separator(); - if (!Plugin.Config.AutoTellTabsCompactDisplay) - { - ImGui.TextDisabled( - $"{HellionStrings.PinTab_SectionHeader} ({pinnedCount})" - ); - } - pinnedHeaderRendered = true; - } - else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !tempTabHeaderRendered) - { - ImGui.Separator(); - if (!Plugin.Config.AutoTellTabsCompactDisplay) - { - ImGui.TextDisabled( - $"{HellionStrings.AutoTellTabs_SectionHeader} ({unpinnedTempCount})" - ); - } - tempTabHeaderRendered = true; - } - - var unread = - tabI == Plugin.LastTab - || tab.UnreadMode == UnreadMode.None - || tab.Unread == 0 - ? "" - : $" ({tab.Unread})"; - var isCurrentTab = Plugin.LastTab == tabI || Plugin.WantedTab == tabI; - - var showGreetedAffordance = - tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle; - - if (showGreetedAffordance) - { - // Greeted toggle left of the selectable to keep click areas separate. - // Compact padding keeps the icon next to the tab name. - var greetedIcon = tab.IsGreeted - ? FontAwesomeIcon.CheckCircle - : FontAwesomeIcon.Check; - var greetedTooltip = tab.IsGreeted - ? HellionStrings.AutoTellTabs_GreetedTooltip - : HellionStrings.AutoTellTabs_UnGreetedTooltip; - - using (ImRaii.PushStyle(ImGuiStyleVar.FramePadding, new Vector2(2, 1))) - using (ImRaii.PushColor(ImGuiCol.Button, 0)) - { - if ( - ImGuiUtil.IconButton(greetedIcon, $"greeted-{tabI}", greetedTooltip) - ) - { - if (tab.IsGreeted) - { - Plugin.AutoTellTabsService.UnmarkGreeted(tab); - } - else - { - Plugin.AutoTellTabsService.MarkGreeted(tab); - } - } - } - ImGui.SameLine(); - } - - // Icon-only sidebar with tooltip on hover. Active tab gets accent color; - // greeted tabs are dimmed; tell tabs get a hash-based tint. - var theme = Plugin.ThemeRegistry.Active; - var icon = TabIconMapping.Resolve(tab); - uint iconColor; - if (isCurrentTab) - { - iconColor = theme.Colors.Accent; - } - else if (showGreetedAffordance && tab.IsGreeted) - { - iconColor = theme.Colors.TextDim; - } - else if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet()) - { - // Hash-based color tint differentiates parallel Auto-Tell tabs - // without requiring manual icon assignment per tab. - iconColor = TabTintCache.GetTint(tab); - } - else - { - iconColor = theme.Colors.TextPrimary; - } - - bool clicked; - using (ImRaii.PushColor(ImGuiCol.Button, 0u)) - using ( - ImRaii.PushColor( - ImGuiCol.ButtonHovered, - ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover) - ) - ) - using ( - ImRaii.PushColor( - ImGuiCol.ButtonActive, - ColourUtil.RgbaToAbgr(theme.Colors.Surface) - ) - ) - // PM-3c: icon alpha eases from 40% (dim) to 100% on - // hover. _hoverAlpha lerps at the end of this block, - // so the colour for frame N uses frame N-1's value -- - // a sub-frame lag that is invisible at 10f speed. - using ( - ImRaii.PushColor( - ImGuiCol.Text, - ColourUtil.ApplyAlpha( - ColourUtil.RgbaToAbgr(iconColor), - 0.4f + 0.6f * tab._hoverAlpha - ) - ) - ) - using (Plugin.FontManager.FontAwesome.Push()) - { - // Button stretches with the configured sidebar width so a - // user-widened sidebar feels intentional, not a 36px icon - // floating in empty space. - clicked = ImGui.Button( - $"{icon.ToIconString()}##sidebar-tab-{tabI}", - new Vector2(sidebarWidth - 8f, ImGui.GetFrameHeight()) - ); - } - - // PM-3c hover-lerp: ramp _hoverAlpha toward 1 while the - // icon button is hovered, back to 0 otherwise. - // ReduceMotion snaps so the dim/full states stay binary. - var hoverTarget = ImGui.IsItemHovered() ? 1f : 0f; - tab._hoverAlpha = Plugin.Config.ReduceMotion - ? hoverTarget - : FrameLerp.Smooth( - tab._hoverAlpha, - hoverTarget, - speed: 10f, - deltaTime: ImGui.GetIO().DeltaTime - ); - - if (isCurrentTab) - { - // Vertical accent pill on the left window edge, 3px wide, half tab height, - // vertically centered. Direct DrawList pass, no native ImGui API for this. - var min = ImGui.GetItemRectMin(); - var max = ImGui.GetItemRectMax(); - const float pillWidth = 3f; - var pillHeight = (max.Y - min.Y) * 0.5f; - var pillCenterY = (min.Y + max.Y) * 0.5f; - ImGui - .GetWindowDrawList() - .AddRectFilled( - new Vector2(min.X, pillCenterY - pillHeight * 0.5f), - new Vector2(min.X + pillWidth, pillCenterY + pillHeight * 0.5f), - ColourUtil.RgbaToAbgr(theme.Colors.Accent), - 1.5f - ); // leichter Rounding - } - - // Unread dot top-right of the icon. Active tabs have Unread=0 by convention - // so the dot never conflicts with the active pill. - if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0) - { - var min = ImGui.GetItemRectMin(); - var max = ImGui.GetItemRectMax(); - const float dotRadius = 4f; - const float dotPadding = 3f; - var dotCenter = new Vector2( - max.X - dotRadius - dotPadding, - min.Y + dotRadius + dotPadding - ); - - // Sin-based 2s pulse: alpha oscillates 60-100%. Skipped when ReduceMotion is on. - var dotColor = theme.Colors.StatusDanger; - if (!Plugin.Config.ReduceMotion) - { - // Sin-basierter 2s-Cycle: -1..1 → 0..1 → 0.6..1.0 Alpha-Skala. - var phase = (float)( - (Math.Sin(Environment.TickCount64 / 1000.0 * Math.PI) + 1.0) * 0.5 - ); - var alphaScale = 0.6f + 0.4f * phase; - var origAlpha = dotColor & 0xFFu; - var pulsedAlpha = (uint)(origAlpha * alphaScale); - dotColor = (dotColor & 0xFFFFFF00u) | pulsedAlpha; - } - - ImGui - .GetWindowDrawList() - .AddCircleFilled( - dotCenter, - dotRadius, - ColourUtil.RgbaToAbgr(dotColor), - 12 - ); - } - - // Pin indicator: subtle thumbtack glyph top-left of the icon. - // Muted colour because the "Pinned" section header already - // groups these tabs visually — this is just a per-tab - // confirmation glyph, not the primary discoverability cue. - if (tab.IsPinned) - { - var min = ImGui.GetItemRectMin(); - const float pinPadding = 1f; - var pinPos = new Vector2(min.X + pinPadding, min.Y + pinPadding); - var pinColor = theme.Colors.TextMuted; - // Dim further so the glyph reads as a hint, not a badge. - var pinAbgr = ColourUtil.RgbaToAbgr(pinColor) & 0x77FFFFFFu; - using (Plugin.FontManager.FontAwesome.Push()) - { - ImGui - .GetWindowDrawList() - .AddText(pinPos, pinAbgr, FontAwesomeIcon.Thumbtack.ToIconString()); - } - } - - // Tooltip mit Tab-Name + Unread-Counter beim Hover. - if (ImGui.IsItemHovered()) - { - using var tt = ImRaii.Tooltip(); - ImGui.TextUnformatted($"{tab.Name}{unread}"); - if (tab.IsPinned) - { - ImGui.TextUnformatted(HellionStrings.PinTab_PinnedTooltip); - } - } - - DrawTabContextMenu(tab, tabI); - - if (clicked) - Plugin.WantedTab = tabI; - - if (!clicked && Plugin.WantedTab != tabI) - continue; - - currentTab = tabI; - hasTabSwitched = Plugin.LastTab != tabI; - Plugin.LastTab = tabI; - if (hasTabSwitched) - TabSwitched(tab, previousTab); - } - } - } - - ImGui.TableNextColumn(); - - if (currentTab == -1 && Plugin.LastTab < Plugin.Config.Tabs.Count) - { - currentTab = Plugin.LastTab; - Plugin.Config.Tabs[currentTab].Unread = 0; - } - - if (currentTab > -1) - { - DrawChatHeaderToolbar(Plugin.Config.Tabs[currentTab]); - DrawMessageLog( - Plugin.Config.Tabs[currentTab], - PayloadHandler, - childHeight, - hasTabSwitched - ); - } - - Plugin.WantedTab = null; - } - - // DrawChatHeaderToolbar: renders the honorific title slot, the optional - // scroll-to-bottom button, and the pop-out button for the active tab. - private void DrawChatHeaderToolbar(Tab tab) - { - DrawHonorificTitleSlot(); - DrawScrollToBottomToolbarButton(); - DrawPopOutButton(tab); - } - - // Draws an arrow-down button in the toolbar when the user has scrolled up - // from the live end of the chat log. Clicking it requests a snap to bottom. - // - // _childScrolledUp is set at the end of DrawMessageLog, which runs AFTER - // DrawChatHeaderToolbar in the same frame. So this button always reflects the - // previous frame's scroll state, a one-frame lag that is imperceptible in use. - // - // Both this button and DrawPopOutButton use SetCursorPosX with absolute - // positioning (cursorX + GetContentRegionAvail().X - N * iconWidth). Because - // each call computes its own target X from the right edge, they are independent - // of each other and of what the cursor position happens to be at call time. - // The pop-out button lands at rightEdge - iconWidth regardless of call order. - private void DrawScrollToBottomToolbarButton() - { - if (!_childScrolledUp) - return; - - var avail = ImGui.GetContentRegionAvail().X; - var iconWidth = ImGui.GetFrameHeight(); - var spacing = ImGui.GetStyle().ItemSpacing.X; - ImGui.SetCursorPosX(ImGui.GetCursorPosX() + avail - 2 * iconWidth - spacing); - - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.ArrowDown, - tooltip: HellionStrings.ChatLog_ScrollToBottom_Tooltip - ) - ) - _scrollToBottomRequested = true; - - // Keep the pop-out button on the same toolbar row. Without this the - // button item ends the line and the pop-out drops to the next row. - ImGui.SameLine(); - } - - private void DrawPopOutButton(Tab tab) - { - var avail = ImGui.GetContentRegionAvail().X; - var iconWidth = ImGui.GetFrameHeight(); - ImGui.SetCursorPosX(ImGui.GetCursorPosX() + avail - iconWidth); - - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.WindowRestore, - tooltip: Language.ChatLog_Tabs_PopOut - ) - ) - { - tab.PopOut = true; - Plugin.SaveConfig(); - } - } - - // Title rendered first so DrawPopOutButton can anchor flush right via - // GetContentRegionAvail. Call order in DrawChatHeaderToolbar matters. - // SameLine keeps both on the same toolbar row. - private void DrawHonorificTitleSlot() - { - var service = Plugin.HonorificService; - var title = service.CurrentTitle; - if ( - !HonorificService.ShouldRenderSlot( - Plugin.Config.ShowHonorificTitleInHeader, - service.IsAvailable, - title - ) - ) - { - return; - } - - // Reserve space for the crown icon plus a small gap before the title, - // then the title itself, then the gap-to-pop-out-button. We measure the - // crown width inside the FontAwesome font push because FontAwesome - // glyphs render in a different font than the regular ImGui text. - const float gapAfterCrown = 4f; - const float gapBeforeButton = 8f; - var avail = ImGui.GetContentRegionAvail().X; - var iconWidth = ImGui.GetFrameHeight(); - - float crownWidth; - using (Plugin.FontManager.FontAwesome.Push()) - { - crownWidth = ImGui.CalcTextSize(FontAwesomeIcon.Crown.ToIconString()).X; - } - - // When the scroll button is also present it occupies iconWidth + ItemSpacing.X - // to the left of the pop-out button, so shrink the title budget accordingly. - var scrollButtonReserve = _childScrolledUp - ? iconWidth + ImGui.GetStyle().ItemSpacing.X - : 0f; - var maxTitleWidth = - avail - iconWidth - scrollButtonReserve - gapBeforeButton - crownWidth - gapAfterCrown; - if (maxTitleWidth <= 0) - { - return; - } - - var rendered = "«" + title!.Title + "»"; - rendered = StringUtil.TruncateToFitWidth(rendered, maxTitleWidth); - - var titleColor = title.Color is { } c - ? new Vector4(c.X, c.Y, c.Z, 1f) - : ImGui.GetStyle().Colors[(int)ImGuiCol.Text]; - - var theme = Plugin.ThemeRegistry.Active; - - // Group so IsItemHovered covers both the crown icon and the title text. - ImGui.BeginGroup(); - using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted))) - using (Plugin.FontManager.FontAwesome.Push()) - { - ImGui.TextUnformatted(FontAwesomeIcon.Crown.ToIconString()); - } - ImGui.SameLine(0f, gapAfterCrown); - DrawHonorificTitleText(rendered, titleColor, title.Glow); - ImGui.EndGroup(); - - if (ImGui.IsItemHovered()) - { - ImGui.SetTooltip(HellionStrings.ChatHeader_HonorificTitle_Tooltip); - } - - ImGui.SameLine(); - } - - // Renders the title text, optionally with a glow outline pre-pass. Glow is - // drawn at 8 cardinal offsets (±1 px) in the glow colour at reduced alpha, - // then the primary text on top. The pre-pass uses the window draw list so - // it composites correctly with the regular ImGui text that follows. - private void DrawHonorificTitleText(string rendered, Vector4 titleColor, Vector3? glow) - { - if (Plugin.Config.ShowHonorificGlow && glow is { } g) - { - var pos = ImGui.GetCursorScreenPos(); - var glowColor = new Vector4(g.X, g.Y, g.Z, 0.4f); - var glowAbgr = ImGui.ColorConvertFloat4ToU32(glowColor); - var drawList = ImGui.GetWindowDrawList(); - for (var dy = -1; dy <= 1; dy++) - { - for (var dx = -1; dx <= 1; dx++) - { - if (dx == 0 && dy == 0) - continue; - drawList.AddText(new Vector2(pos.X + dx, pos.Y + dy), glowAbgr, rendered); - } - } - } - - using (ImRaii.PushColor(ImGuiCol.Text, titleColor)) - { - ImGui.TextUnformatted(rendered); - } - } - - // One-time hint banner for the pop-out header button and right-click pathway. - private float DrawV061HintBannerIfNeeded() - { - if (Plugin.Config.SeenPopOutHeaderHint) - return 0f; - - var hintText = Resources.HellionStrings.Hint_v061_PopOutHeader_Body; - var ackLabel = Resources.HellionStrings.Hint_v061_PopOutHeader_Ack; - var openLabel = Resources.HellionStrings.Hint_v061_PopOutHeader_OpenSettings; - - var startY = ImGui.GetCursorPosY(); - - var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f); - var dismiss = false; - var openSettings = false; - // RAII style stack so an early return can never leave ImGui unbalanced. - using (ImRaii.PushColor(ImGuiCol.ChildBg, bg)) - using (ImRaii.PushStyle(ImGuiStyleVar.FrameBorderSize, 1f)) - using ( - var child = ImRaii.Child( - "##v061-pop-out-header-hint", - new System.Numerics.Vector2(0f, 84f), - true - ) - ) - { - if (child) - { - ImGui.TextWrapped(hintText); - if (ImGui.Button(ackLabel)) - dismiss = true; - ImGui.SameLine(); - if (ImGui.Button(openLabel)) - { - dismiss = true; - openSettings = true; - } - } - } - - ImGui.Spacing(); - - if (dismiss) - { - Plugin.Config.SeenPopOutHeaderHint = true; - Plugin.SaveConfig(); - _logger.LogDebug("v0.6.1 pop-out header hint dismissed"); - if (openSettings) - Plugin.SettingsWindow.Toggle(); - } - - return ImGui.GetCursorPosY() - startY; - } - - private void DrawTabContextMenu(Tab tab, int i) - { - using var contextMenu = ImRaii.ContextPopupItem($"tab-context-menu-{i}"); - if (!contextMenu.Success) - return; - - var anyChanged = false; - var tabs = Plugin.Config.Tabs; - - // Focus the rename field on the frame the context menu opens so the - // user can type immediately. Buffer raised 128 -> 512 to match the - // settings-tab rename (Ui/SettingsTabs/Tabs.cs). One name limit, not two. - if (ImGui.IsWindowAppearing()) - ImGui.SetKeyboardFocusHere(); - ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale); - if (ImGui.InputText("##tab-name", ref tab.Name, 512)) - anyChanged = true; - - if (ImGuiUtil.IconButton(FontAwesomeIcon.TrashAlt, tooltip: Language.ChatLog_Tabs_Delete)) - { - tabs.RemoveAt(i); - Plugin.WantedTab = 0; - - anyChanged = true; - } - - ImGui.SameLine(); - - var (leftIcon, leftTooltip) = Plugin.Config.SidebarTabView - ? (FontAwesomeIcon.ArrowUp, Language.ChatLog_Tabs_MoveUp) - : (FontAwesomeIcon.ArrowLeft, Language.ChatLog_Tabs_MoveLeft); - if (ImGuiUtil.IconButton(leftIcon, tooltip: leftTooltip) && i > 0) - { - (tabs[i - 1], tabs[i]) = (tabs[i], tabs[i - 1]); - ImGui.CloseCurrentPopup(); - anyChanged = true; - } - - ImGui.SameLine(); - - var (rightIcon, rightTooltip) = Plugin.Config.SidebarTabView - ? (FontAwesomeIcon.ArrowDown, Language.ChatLog_Tabs_MoveDown) - : (FontAwesomeIcon.ArrowRight, Language.ChatLog_Tabs_MoveRight); - if (ImGuiUtil.IconButton(rightIcon, tooltip: rightTooltip) && i < tabs.Count - 1) - { - (tabs[i + 1], tabs[i]) = (tabs[i], tabs[i + 1]); - ImGui.CloseCurrentPopup(); - anyChanged = true; - } - - ImGui.SameLine(); - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.WindowRestore, - tooltip: Language.ChatLog_Tabs_PopOut - ) - ) - { - tab.PopOut = true; - anyChanged = true; - } - - if (tab.IsTempTab) - { - ImGui.Separator(); - DrawPinControls(tab); - } - - if (anyChanged) - Plugin.SaveConfig(); - } - - private void DrawPinControls(Tab tab) - { - var svc = Plugin.AutoTellTabsService; - if (svc == null) - return; - - if (tab.IsPinned) - { - if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin)) - { - svc.Unpin(tab); - ImGui.CloseCurrentPopup(); - } - } - else - { - var atCap = svc.PinnedTempTabCount >= AutoTellTabsService.MaxPinnedTempTabs; - if (ImGui.MenuItem(HellionStrings.PinTab_MenuPin, enabled: !atCap)) - { - if (svc.TryPin(tab)) - ImGui.CloseCurrentPopup(); - } - if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) - { - ImGui.SetTooltip( - atCap - ? string.Format( - HellionStrings.PinTab_LimitReached, - AutoTellTabsService.MaxPinnedTempTabs - ) - : HellionStrings.PinTab_PinTooltip - ); - } - } - } - - internal readonly List PopOutDocked = []; - internal readonly HashSet PopOutWindows = []; - - // Live enumeration of active Popout windows for KeybindManager tab-cycle forwarding. - // Filters on IsOpen to skip closed-but-registered popouts. - internal IEnumerable ActivePopouts => - Plugin.WindowSystem.Windows.OfType().Where(p => p.IsOpen); - - private void AddPopOutsToDraw() - { - HandlerLender.ResetCounter(); - - if (PopOutDocked.Count != Plugin.Config.Tabs.Count) - { - PopOutDocked.Clear(); - PopOutDocked.AddRange(Enumerable.Repeat(false, Plugin.Config.Tabs.Count)); - } - - for (var i = 0; i < Plugin.Config.Tabs.Count; i++) - { - var tab = Plugin.Config.Tabs[i]; - if (!tab.PopOut) - continue; - - if (PopOutWindows.Contains(tab.Identifier)) - continue; - - var window = new Popout(this, tab, i, _loggerFactory.CreateLogger()); - - Plugin.WindowSystem.AddWindow(window); - PopOutWindows.Add(tab.Identifier); - } - } - - private unsafe void DrawAutoComplete() - { - if (AutoCompleteInfo == null) - return; - - AutoCompleteList ??= AutoTranslate.Matching( - AutoCompleteInfo.ToComplete, - Plugin.Config.SortAutoTranslate - ); - if (AutoCompleteOpen) - { - ImGui.OpenPopup(AutoCompleteId); - AutoCompleteOpen = false; - } - - ImGui.SetNextWindowSize(new Vector2(400, 300) * ImGuiHelpers.GlobalScale); - using var popup = ImRaii.Popup(AutoCompleteId); - if (!popup.Success) - { - if (ActivatePos == -1) - ActivatePos = AutoCompleteInfo.EndPos; - - AutoCompleteInfo = null; - AutoCompleteList = null; - Activate = true; - return; - } - - ImGui.SetNextItemWidth(-1); - if ( - ImGui.InputTextWithHint( - "##auto-complete-filter", - Language.AutoTranslate_Search_Hint, - ref AutoCompleteInfo.ToComplete, - 256, - ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackHistory, - AutoCompleteCallback - ) - ) - { - AutoCompleteList = AutoTranslate.Matching( - AutoCompleteInfo.ToComplete, - Plugin.Config.SortAutoTranslate - ); - AutoCompleteSelection = 0; - AutoCompleteShouldScroll = true; - } - - var selected = -1; - if (ImGui.IsItemActive() && ImGui.GetIO().KeyCtrl) - { - for (var i = 0; i < 10 && i < AutoCompleteList.Count; i++) - { - var num = (i + 1) % 10; - var key = ImGuiKey.Key0 + num; - var key2 = ImGuiKey.Keypad0 + num; - if (ImGui.IsKeyDown(key) || ImGui.IsKeyDown(key2)) - selected = i; - } - } - - if (ImGui.IsItemDeactivated()) - { - if (ImGui.IsKeyDown(ImGuiKey.Escape)) - { - ImGui.CloseCurrentPopup(); - return; - } - - var enter = ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter); - if (AutoCompleteList.Count > 0 && enter) - selected = AutoCompleteSelection; - } - - if (ImGui.IsWindowAppearing()) - { - FixCursor = true; - ImGui.SetKeyboardFocusHere(-1); - } - - using var child = ImRaii.Child( - "##auto-complete-list", - Vector2.Zero, - false, - ImGuiWindowFlags.HorizontalScrollbar - ); - if (!child.Success) - return; - - var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper()); - try - { - clipper.Begin(AutoCompleteList.Count); - while (clipper.Step()) - { - for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) - { - var entry = AutoCompleteList[i]; - - var highlight = AutoCompleteSelection == i; - var clicked = - ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight) - || selected == i; - if (i < 10) - { - var button = (i + 1) % 10; - var text = string.Format(Language.AutoTranslate_Completion_Key, button); - var size = ImGui.CalcTextSize(text); - - ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X); - - using ( - ImRaii.PushColor( - ImGuiCol.Text, - ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled] - ) - ) - ImGui.TextUnformatted(text); - } - - if (!clicked) - continue; - - var before = Chat[..AutoCompleteInfo.StartPos]; - var after = Chat[AutoCompleteInfo.EndPos..]; - var replacement = $""; - Chat = $"{before}{replacement}{after}"; - ImGui.CloseCurrentPopup(); - Activate = true; - ActivatePos = AutoCompleteInfo.StartPos + replacement.Length; - } - } - - if (!AutoCompleteShouldScroll) - return; - - AutoCompleteShouldScroll = false; - var selectedPos = - clipper.StartPosY + clipper.ItemsHeight * (AutoCompleteSelection * 1f); - ImGui.SetScrollFromPosY(selectedPos - ImGui.GetWindowPos().Y); - } - finally - { - // Destroy frees the unmanaged ImGuiListClipper allocated above; without it the block leaks per render. - clipper.Destroy(); - } - } - - private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data) - { - if (FixCursor && AutoCompleteInfo != null) - { - FixCursor = false; - data.CursorPos = AutoCompleteInfo.ToComplete.Length; - data.SelectionStart = data.SelectionEnd = data.CursorPos; - } - - if (AutoCompleteList == null) - return 0; - - switch (data.EventKey) - { - case ImGuiKey.UpArrow: - if (AutoCompleteSelection == 0) - AutoCompleteSelection = AutoCompleteList.Count - 1; - else - AutoCompleteSelection--; - - AutoCompleteShouldScroll = true; - return 1; - case ImGuiKey.DownArrow: - if (AutoCompleteSelection == AutoCompleteList.Count - 1) - AutoCompleteSelection = 0; - else - AutoCompleteSelection++; - - AutoCompleteShouldScroll = true; - return 1; - default: - if (ImGui.IsKeyPressed(ImGuiKey.Tab)) - { - if (AutoCompleteSelection == AutoCompleteList.Count - 1) - AutoCompleteSelection = 0; - else - AutoCompleteSelection++; - - AutoCompleteShouldScroll = true; - return 1; - } - break; - } - - return 0; - } - - private unsafe int Callback(scoped ref ImGuiInputTextCallbackData data) - { - // We play the opening sound here only if closing sound has been played before - if (Plugin.Config.PlaySounds && PlayedClosingSound) - { - PlayedClosingSound = false; - UIGlobals.PlaySoundEffect(ChatOpenSfx); - } - - // Set the cursor pos to the user selected - if (Plugin.InputPreview.SelectedCursorPos != -1) - data.CursorPos = Plugin.InputPreview.SelectedCursorPos; - Plugin.InputPreview.SelectedCursorPos = -1; - - CursorPos = data.CursorPos; - if (data.EventFlag == ImGuiInputTextFlags.CallbackCompletion) - { - if (data.CursorPos == 0) - { - AutoCompleteInfo = new AutoCompleteInfo( - string.Empty, - data.CursorPos, - data.CursorPos - ); - AutoCompleteOpen = true; - AutoCompleteSelection = 0; - - return 0; - } - - int white; - for (white = data.CursorPos - 1; white >= 0; white--) - if (data.Buf[white] == ' ') - break; - - var start = data.Buf + white + 1; - var end = data.CursorPos - white - 1; - var utf8Message = Marshal.PtrToStringUTF8((nint)start, end); - var correctedCursor = data.CursorPos - (end - utf8Message.Length); - AutoCompleteInfo = new AutoCompleteInfo(utf8Message, white + 1, correctedCursor); - AutoCompleteOpen = true; - AutoCompleteSelection = 0; - return 0; - } - - if (data.EventFlag == ImGuiInputTextFlags.CallbackCharFilter) - if (!Plugin.Functions.Chat.IsCharValid((char)data.EventChar)) - return 1; - - if (Activate) - { - Activate = false; - data.CursorPos = ActivatePos > -1 ? ActivatePos : Chat.Length; - data.SelectionStart = data.SelectionEnd = data.CursorPos; - ActivatePos = -1; - } - - Plugin.CommandHelpWindow.IsOpen = false; - var text = MemoryHelper.ReadString((nint)data.Buf, data.BufTextLen); - if (text.StartsWith('/')) - { - var command = text.Split(' ')[0]; - if (AllCommands.TryGetValue(command, out var textCommand)) - Plugin.CommandHelpWindow.UpdateContent(textCommand.Description); - else if ( - Plugin.CommandManager.Commands.TryGetValue(command, out var info) && info.ShowInHelp - ) - Plugin.CommandHelpWindow.UpdateContent(info.HelpMessage); - } - - if (data.EventFlag != ImGuiInputTextFlags.CallbackHistory) - return 0; - - var prevPos = InputBacklogIdx; - switch (data.EventKey) - { - case ImGuiKey.UpArrow: - switch (InputBacklogIdx) - { - case -1: - var offset = 0; - - if (!string.IsNullOrWhiteSpace(Chat)) - { - AddBacklog(Chat); - offset = 1; - } - - InputBacklogIdx = InputHistoryService.Count - 1 - offset; - break; - case > 0: - InputBacklogIdx--; - break; - } - break; - case ImGuiKey.DownArrow: - if (InputBacklogIdx != -1) - if (++InputBacklogIdx >= InputHistoryService.Count) - InputBacklogIdx = -1; - break; - } - - if (prevPos == InputBacklogIdx) - return 0; - - var historyStr = InputHistoryService.GetByCursor(InputBacklogIdx) ?? string.Empty; - data.DeleteChars(0, data.BufTextLen); - data.InsertChars(0, historyStr); - - return 0; - } - - internal void DrawChunks( - IReadOnlyList chunks, - bool wrap = true, - PayloadHandler? handler = null, - float lineWidth = 0f - ) - { - // UI-7: render a copy with the sender name reformatted per the user's - // display options. Skipped in screenshot mode so the name-anonymising - // path in DrawChunk stays reliable (privacy wins). ForDisplay returns - // the list unchanged when nothing applies, so non-sender lists and the - // neutral default cost only a quick scan. - if (!ScreenshotMode) - chunks = SenderNameDisplay.ForDisplay(chunks); - - using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - - for (var i = 0; i < chunks.Count; i++) - { - if (chunks[i] is TextChunk text && string.IsNullOrEmpty(text.Content)) - continue; - - DrawChunk(chunks[i], wrap, handler, lineWidth); - - if (i < chunks.Count - 1) - { - ImGui.SameLine(); - } - else if (chunks[i].Link is EmotePayload && Plugin.Config.ShowEmotes) - { - // Emote payloads seem to not automatically put newlines, which - // is an issue when modern mode is disabled. - ImGui.SameLine(); - // Use default ImGui behavior for newlines. - ImGui.TextUnformatted(""); - } - } - } - - private void DrawChunk( - Chunk chunk, - bool wrap = true, - PayloadHandler? handler = null, - float lineWidth = 0f - ) - { - if (chunk is IconChunk icon) - { - DrawIcon(chunk, icon, handler); - return; - } - - if (chunk is not TextChunk text) - return; - - if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes) - { - var emoteSize = ImGui.CalcTextSize("W"); - emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f; - - // TextWrap doesn't work for emotes, so we have to wrap them manually - if (ImGui.GetContentRegionAvail().X < emoteSize.X) - ImGui.NewLine(); - - // We only draw a dummy if it is still loading, in the case it failed we draw the actual name - var image = EmoteCache.GetEmote(emotePayload.Code); - if (image is { Failed: false }) - { - if (image.IsLoaded) - image.Draw(emoteSize); - else - ImGui.Dummy(emoteSize); - - if (ImGui.IsItemHovered()) - ImGuiUtil.Tooltip(emotePayload.Code); - - return; - } - } - - var colour = text.Foreground; - if (colour == null && text.FallbackColour != null) - { - var type = text.FallbackColour.Value; - colour = Plugin.Config.ChatColours.TryGetValue(type, out var col) - ? col - : type.DefaultColor(); - } - - var push = colour != null; - var uColor = push ? ColourUtil.RgbaToAbgr(colour!.Value) : 0; - using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, uColor, push); - - var useCustomItalicFont = - Plugin.Config.FontsEnabled && Plugin.FontManager.ItalicFont != null; - if (text.Italic) - ( - useCustomItalicFont ? Plugin.FontManager.ItalicFont! : Plugin.FontManager.AxisItalic - ).Push(); - - // Check for contains here as sometimes there are multiple - // TextChunks with the same PlayerPayload but only one has the name. - // E.g. party chat with cross world players adds extra chunks. - // - // Note: This has been null before, I'm guessing due to some issues with - // other plugins. New TextChunks will now enforce empty string in ctor, - // but old ones may still be null. - // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract - var content = text.Content ?? ""; - if (ScreenshotMode) - { - if (chunk.Link is PlayerPayload playerPayload) - content = HidePlayerInString( - content, - playerPayload.PlayerName, - playerPayload.World.RowId - ); - else if (Plugin.PlayerState.IsLoaded) - content = HidePlayerInString( - content, - Plugin.PlayerState.CharacterName, - Plugin.PlayerState.HomeWorld.RowId - ); - } - - if (wrap) - { - ImGuiUtil.WrapText(content, chunk, handler, DefaultText, lineWidth); - } - else - { - ImGui.TextUnformatted(content); - ImGuiUtil.PostPayload(chunk, handler); - } - - if (text.Italic) - ( - useCustomItalicFont ? Plugin.FontManager.ItalicFont! : Plugin.FontManager.AxisItalic - ).Pop(); - } - - internal void DrawIcon(Chunk chunk, IconChunk icon, PayloadHandler? handler) - { - if (!IconUtil.GfdFileView.TryGetEntry((uint)icon.Icon, out var entry)) - return; - - var iconTexture = Plugin - .TextureProvider.GetFromGame("common/font/fonticon_ps5.tex") - .GetWrapOrDefault(); - if (iconTexture == null) - return; - - var texSize = new Vector2(iconTexture.Width, iconTexture.Height); - - var sizeRatio = FontManager.GetFontSize() / entry.Height; - var size = new Vector2(entry.Width, entry.Height) * sizeRatio * ImGuiHelpers.GlobalScale; - - var uv0 = new Vector2(entry.Left, entry.Top + 170) * 2 / texSize; - var uv1 = - new Vector2(entry.Left + entry.Width, entry.Top + entry.Height + 170) * 2 / texSize; - - ImGui.Image(iconTexture.Handle, size, uv0, uv1); - ImGuiUtil.PostPayload(chunk, handler); - } - - internal string HidePlayerInString(string str, string playerName, uint worldId) - { - var expected = Plugin.Functions.Chat.AbbreviatePlayerName(playerName); - var hash = HashPlayer(playerName, worldId); - return str.Replace(playerName, expected).Replace(expected, hash); - } - - private string HashPlayer(string playerName, uint worldId) - { - var hashCode = $"{Salt}{playerName}{worldId}".GetHashCode(); - return $"Player {hashCode:X8}"; - } - - // Snap threshold: minimum window overlap with a visible viewport before - // we consider it off-screen. - private const int OnScreenMinOverlapX = 100; - private const int OnScreenMinOverlapY = 40; - - // Default snap position relative to the primary viewport (top-left with a - // safety margin from the game title bar). - private static readonly Vector2 SafeDefaultOffset = new(50, 50); - - private void EnsureWindowOnScreen(string source) - { - if (LastWindowSize.X < 1 || LastWindowSize.Y < 1) - return; - - var viewport = ImGui.GetMainViewport(); - var visibleMin = viewport.WorkPos; - var visibleMax = viewport.WorkPos + viewport.WorkSize; - - var overlapMin = Vector2.Max(LastWindowPos, visibleMin); - var overlapMax = Vector2.Min(LastWindowPos + LastWindowSize, visibleMax); - var overlap = overlapMax - overlapMin; - - if (overlap.X >= OnScreenMinOverlapX && overlap.Y >= OnScreenMinOverlapY) - return; - - ApplySafeDefaultPosition(source); - } - - private void ApplySafeDefaultPosition(string source) - { - var viewport = ImGui.GetMainViewport(); - var safePos = viewport.WorkPos + SafeDefaultOffset; - Position = safePos; - _logger.LogInformation( - $"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}." - ); - - // Pop-outs don't persist across sessions so they can never end up off-screen - // after a reload. Only the main window needs explicit recovery. - } -} diff --git a/HellionChat/Ui/CommandHelpWindow.cs b/HellionChat/Ui/CommandHelpWindow.cs index 50308e8..0d4caa4 100644 --- a/HellionChat/Ui/CommandHelpWindow.cs +++ b/HellionChat/Ui/CommandHelpWindow.cs @@ -3,20 +3,32 @@ using Dalamud.Bindings.ImGui; using Dalamud.Interface.Utility; using Dalamud.Interface.Windowing; using Dalamud.Utility; +using HellionChat.Ui.Components; using HellionChat.Util; using Lumina.Text.ReadOnly; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui; -public class CommandHelpWindow : Window +internal sealed class CommandHelpWindow : Window { - private ChatLogWindow LogWindow { get; } - private ReadOnlySeString? CommandDescription { get; set; } + private readonly ChunkRenderer _chunkRenderer; + private readonly ILogger _logger; - internal CommandHelpWindow(ChatLogWindow logWindow) + // Setter-injected post-ctor to break the InputBar -> CommandHelpWindow -> + // MainWindow -> InputBar singleton cycle (MS.DI does not detect cycles + // through FactoryCallSite registrations). Wired in + // CommandHelpWindowInitHostedService.StartAsync, same §6.2 pattern as + // MessageList.AttachPayloadHandler. + private Windows.MainWindow? _mainWindow; + + private ReadOnlySeString? _commandDescription; + + internal CommandHelpWindow(ChunkRenderer chunkRenderer, ILogger logger) : base("command help##chat2-commandhelp") { - LogWindow = logWindow; + _chunkRenderer = chunkRenderer; + _logger = logger; Flags = ImGuiWindowFlags.NoSavedSettings @@ -28,20 +40,32 @@ public class CommandHelpWindow : Window RespectCloseHotkey = false; DisableWindowSounds = true; + + // Logger injected for future diagnostic hooks (no call-sites yet in R2). + _ = _logger; } - // Sets IsOpen to true if it should be drawn + internal void AttachMainWindow(Windows.MainWindow mainWindow) => _mainWindow = mainWindow; + public void UpdateContent(ReadOnlySeString commandDesc) { - CommandDescription = commandDesc; + // Loud-fail if the HostedService didn't run AttachMainWindow before + // the first slash-command call — better than a silent NullRef during + // input draw. + if (_mainWindow is null) + throw new InvalidOperationException( + "CommandHelpWindow.UpdateContent called before AttachMainWindow." + ); + + _commandDescription = commandDesc; var width = 350; var scaledWidth = width * ImGuiHelpers.GlobalScale; - var pos = LogWindow.LastWindowPos; + var pos = _mainWindow.LastWindowPos; switch (Plugin.Config.CommandHelpSide) { case CommandHelpSide.Right: - pos.X += LogWindow.LastWindowSize.X; + pos.X += _mainWindow.LastWindowSize.X; break; case CommandHelpSide.Left: pos.X -= scaledWidth; @@ -55,11 +79,10 @@ public class CommandHelpWindow : Window Position = pos; SizeConstraints = new WindowSizeConstraints { - // Use scaledWidth here so the size constraints stay in the same - // coordinate space as Position above; otherwise the help window - // ends up the wrong width at non-100% DPI. + // scaledWidth keeps size constraints in the same coordinate space as + // Position so the help window stays correct width at non-100% DPI. MinimumSize = new Vector2(scaledWidth, 0), - MaximumSize = LogWindow.LastWindowSize with { X = scaledWidth }, + MaximumSize = _mainWindow.LastWindowSize with { X = scaledWidth }, }; IsOpen = true; @@ -67,13 +90,14 @@ public class CommandHelpWindow : Window public override void Draw() { - if (CommandDescription == null) + if (_commandDescription == null) return; - LogWindow.DrawChunks( - ChunkUtil - .ToChunks(CommandDescription.Value.ToDalamudString(), ChunkSource.None, null) - .ToList() - ); + var chunks = ChunkUtil + .ToChunks(_commandDescription.Value.ToDalamudString(), ChunkSource.None, null) + .ToList(); + + // Command-help chunks are read-only description text — no click-targets. + _chunkRenderer.DrawChunks(chunks, wrap: true, handler: null, lineWidth: 0f); } } diff --git a/HellionChat/Ui/Components/ChunkRenderer.cs b/HellionChat/Ui/Components/ChunkRenderer.cs new file mode 100644 index 0000000..5439523 --- /dev/null +++ b/HellionChat/Ui/Components/ChunkRenderer.cs @@ -0,0 +1,239 @@ +using System.Collections.Generic; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text.SeStringHandling.Payloads; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; +using HellionChat.Themes; +using HellionChat.Util; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Components; + +internal sealed class ChunkRenderer +{ + private readonly ThemeRegistry _themes; + private readonly FontManager _fonts; + private readonly ILogger _logger; + private readonly GameFunctions.GameFunctions _gameFunctions; + private readonly string _salt; + + public ChunkRenderer( + ThemeRegistry themes, + FontManager fonts, + ILogger logger, + GameFunctions.GameFunctions gameFunctions + ) + { + _themes = themes; + _fonts = fonts; + _logger = logger; + _gameFunctions = gameFunctions; + // Per-ctor random matches v1.5.6 ChatLogWindow behavior — hashed player + // names change every plugin reload to avoid stable cross-session linkage. + _salt = new Random().Next().ToString(); + + // Not yet consumed in C2/C3; E-task wiring will likely add log call-sites later. + _ = _logger; + } + + // B2-1/B2-2 render-observability: the formatted sender text the real draw + // path actually produced (post-ForDisplay). A SelfTest reads this after + // driving DrawChunks to prove the WorldSuffixMode/NameFormMode reformat + // reached the real render entry — never the helper in isolation. null until + // a sender span is reformatted for display. + internal string? LastRenderedSenderText { get; private set; } + + public void DrawChunks( + IReadOnlyList chunks, + bool wrap = true, + PayloadHandler? handler = null, + float lineWidth = 0f + ) + { + // UI-7: render a copy with the sender name reformatted per the user's + // display options. Skipped in screenshot mode so the name-anonymising + // path in DrawChunk stays reliable (privacy wins). ForDisplay returns + // the list unchanged when nothing applies, so non-sender lists and the + // neutral default cost only a quick scan. + if (!Plugin.Config.ScreenshotMode) + { + var displayed = SenderNameDisplay.ForDisplay(chunks); + // ForDisplay only allocates a NEW list when it actually reformatted + // a sender span (same reference on the neutral default / non-sender + // lists). So this scan runs only when a sender name was reformatted + // for display — zero overhead on the neutral-default hot path. + if (!ReferenceEquals(displayed, chunks)) + { + chunks = displayed; + foreach (var c in chunks) + { + if (c.Source == ChunkSource.Sender && c is TextChunk reformatted) + { + LastRenderedSenderText = reformatted.Content; + break; + } + } + } + } + + using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); + + for (var i = 0; i < chunks.Count; i++) + { + if (chunks[i] is TextChunk text && string.IsNullOrEmpty(text.Content)) + continue; + + DrawChunk(chunks[i], wrap, handler, lineWidth); + + if (i < chunks.Count - 1) + { + ImGui.SameLine(); + } + else if (chunks[i].Link is EmotePayload && Plugin.Config.ShowEmotes) + { + // Emote payloads seem to not automatically put newlines, which + // is an issue when modern mode is disabled. + ImGui.SameLine(); + // Use default ImGui behavior for newlines. + ImGui.TextUnformatted(""); + } + } + } + + private void DrawChunk( + Chunk chunk, + bool wrap = true, + PayloadHandler? handler = null, + float lineWidth = 0f + ) + { + if (chunk is IconChunk iconChunk) + { + DrawIcon(chunk, iconChunk, handler); + return; + } + + if (chunk is not TextChunk text) + return; + + if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes) + { + var emoteSize = ImGui.CalcTextSize("W"); + emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f; + + // TextWrap doesn't work for emotes, so we have to wrap them manually + if (ImGui.GetContentRegionAvail().X < emoteSize.X) + ImGui.NewLine(); + + // We only draw a dummy if it is still loading, in the case it failed we draw the actual name + var image = EmoteCache.GetEmote(emotePayload.Code); + if (image is { Failed: false }) + { + if (image.IsLoaded) + image.Draw(emoteSize); + else + ImGui.Dummy(emoteSize); + + if (ImGui.IsItemHovered()) + ImGuiUtil.Tooltip(emotePayload.Code); + + return; + } + } + + var colour = text.Foreground; + if (colour == null && text.FallbackColour != null) + { + var type = text.FallbackColour.Value; + colour = Plugin.Config.ChatColours.TryGetValue(type, out var col) + ? col + : type.DefaultColor(); + } + + var push = colour != null; + var uColor = push ? ColourUtil.RgbaToAbgr(colour!.Value) : 0; + using var pushedColor = ImRaii.PushColor(ImGuiCol.Text, uColor, push); + + var useCustomItalicFont = Plugin.Config.FontsEnabled && _fonts.ItalicFont != null; + if (text.Italic) + (useCustomItalicFont ? _fonts.ItalicFont! : _fonts.AxisItalic).Push(); + + // Check for contains here as sometimes there are multiple + // TextChunks with the same PlayerPayload but only one has the name. + // E.g. party chat with cross world players adds extra chunks. + // + // Note: This has been null before, I'm guessing due to some issues with + // other plugins. New TextChunks will now enforce empty string in ctor, + // but old ones may still be null. + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + var content = text.Content ?? ""; + if (Plugin.Config.ScreenshotMode) + { + if (chunk.Link is PlayerPayload playerPayload) + content = HidePlayerInString( + content, + playerPayload.PlayerName, + playerPayload.World.RowId + ); + else if (Plugin.PlayerState.IsLoaded) + content = HidePlayerInString( + content, + Plugin.PlayerState.CharacterName, + Plugin.PlayerState.HomeWorld.RowId + ); + } + + var defaultText = ColourUtil.RgbaToVector4(_themes.Active.Colors.TextPrimary); + if (wrap) + { + ImGuiUtil.WrapText(content, chunk, handler, defaultText, lineWidth); + } + else + { + ImGui.TextUnformatted(content); + ImGuiUtil.PostPayload(chunk, handler); + } + + if (text.Italic) + (useCustomItalicFont ? _fonts.ItalicFont! : _fonts.AxisItalic).Pop(); + } + + internal void DrawIcon(Chunk chunk, IconChunk icon, PayloadHandler? handler) + { + if (!IconUtil.GfdFileView.TryGetEntry((uint)icon.Icon, out var entry)) + return; + + var iconTexture = Plugin + .TextureProvider.GetFromGame("common/font/fonticon_ps5.tex") + .GetWrapOrDefault(); + if (iconTexture == null) + return; + + var texSize = new Vector2(iconTexture.Width, iconTexture.Height); + + var sizeRatio = FontManager.GetFontSize() / entry.Height; + var size = new Vector2(entry.Width, entry.Height) * sizeRatio * ImGuiHelpers.GlobalScale; + + var uv0 = new Vector2(entry.Left, entry.Top + 170) * 2 / texSize; + var uv1 = + new Vector2(entry.Left + entry.Width, entry.Top + entry.Height + 170) * 2 / texSize; + + ImGui.Image(iconTexture.Handle, size, uv0, uv1); + ImGuiUtil.PostPayload(chunk, handler); + } + + private string HidePlayerInString(string str, string playerName, uint worldId) + { + var expected = _gameFunctions.Chat.AbbreviatePlayerName(playerName); + var hash = HashPlayer(playerName, worldId); + return str.Replace(playerName, expected).Replace(expected, hash); + } + + private string HashPlayer(string playerName, uint worldId) + { + var hashCode = $"{_salt}{playerName}{worldId}".GetHashCode(); + return $"Player {hashCode:X8}"; + } +} diff --git a/HellionChat/Ui/Components/HonorificHeader.cs b/HellionChat/Ui/Components/HonorificHeader.cs new file mode 100644 index 0000000..d1e2ae3 --- /dev/null +++ b/HellionChat/Ui/Components/HonorificHeader.cs @@ -0,0 +1,109 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using HellionChat.Integrations; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Util; + +namespace HellionChat.Ui.Components; + +// 30px header row pinned to the top of the main chat window. Crown stays +// rendered as a brand anchor even when the Honorific IPC drops out; the +// bracketed title only appears when there is actually a title to show. +internal sealed class HonorificHeader +{ + public const float Height = 30f; + + // SelfTest observables — set on the real Draw path so a headless step can + // assert the gate/colour/truncation outcome instead of re-implementing it. + internal bool LastTitleRendered { get; private set; } + internal uint LastTitleColorAbgr { get; private set; } + internal string? LastRenderedTitle { get; private set; } + + private readonly HonorificService _honorific; + private readonly FontManager _fonts; + private readonly ThemeRegistry _themes; + private readonly TokenResolver _resolver; + + public HonorificHeader( + HonorificService honorific, + FontManager fonts, + ThemeRegistry themes, + TokenResolver resolver + ) + { + _honorific = honorific; + _fonts = fonts; + _themes = themes; + _resolver = resolver; + } + + // Same singleton the AboutTab integrations section uses; lets a SelfTest + // drive the gate branches via HonorificService.TestOnly_SetState. + internal HonorificService GetServiceForSelfTest() => _honorific; + + public void Draw(float maxWidth) + { + LastTitleRendered = false; + LastRenderedTitle = null; + + // First-frame guard: components must not lay out before the atlas + // is finished or text metrics collapse into placeholder widths. + if (!_fonts.FontsReady) + { + ImGui.TextUnformatted("Loading fonts…"); + return; + } + + var theme = _themes.Active; + var origin = ImGui.GetCursorScreenPos(); + var dl = ImGui.GetWindowDrawList(); + + var crownColor = ColourUtil.RgbaToAbgr( + _resolver.Resolve(Token.HonorificCrown, theme.Colors) + ); + var crownGlyph = FontAwesomeIcon.Crown.ToIconString(); + float crownWidth; + using (_fonts.FontAwesome.Push()) + { + crownWidth = ImGui.CalcTextSize(crownGlyph).X; + dl.AddText(origin + new Vector2(0f, 8f), crownColor, crownGlyph); + } + + // Gate the bracketed title through the 1.5.6 contract (toggle, IPC + // availability, IsOriginal, empty-title) — the crown above stays + // unconditional as the permanent brand anchor. NOTE divergence from + // 1.5.6: there a failed gate hid the whole slot incl. crown; here the + // crown persists by design. + if ( + HonorificService.ShouldRenderSlot( + Plugin.Config.ShowHonorificTitleInHeader, + _honorific.IsAvailable, + _honorific.CurrentTitle + ) + ) + { + var current = _honorific.CurrentTitle!; + var titleColor = HonorificTitleColor.ResolveTitleAbgr(current.Color, theme); + LastTitleColorAbgr = titleColor; + + // Budget the title against the row width. CalcTextSize inside + // TruncateToFitWidth measures the *Regular* font, so this must run + // OUTSIDE the FontAwesome.Push block above (crownWidth was measured + // inside it, which is correct). + var maxTitleWidth = maxWidth - crownWidth - 6f - 8f; + if (maxTitleWidth > 0f) + { + var rendered = StringUtil.TruncateToFitWidth($"«{current.Title}»", maxTitleWidth); + LastRenderedTitle = rendered; + dl.AddText(origin + new Vector2(crownWidth + 6f, 8f), titleColor, rendered); + LastTitleRendered = true; + } + } + + // Reserve the row height even when no title rendered so the layout + // below stays stable across IPC reconnect cycles. + ImGui.Dummy(new Vector2(maxWidth, Height)); + } +} diff --git a/HellionChat/Ui/Components/HonorificTitleColor.cs b/HellionChat/Ui/Components/HonorificTitleColor.cs new file mode 100644 index 0000000..850e1db --- /dev/null +++ b/HellionChat/Ui/Components/HonorificTitleColor.cs @@ -0,0 +1,21 @@ +using System.Numerics; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.Components; + +// Resolves the bracketed-title colour for the Honorific header, shared by the +// real header (HonorificHeader) and the settings theme preview (LivePreviewPanel) +// so the fallback never drifts between them. A title colour supplied by Honorific +// (0..1 normalised RGB over IPC) renders as-is; absent colour falls back to the +// theme's primary text. The Vector4ToRgba path clamps each component to [0,1] so +// an out-of-range value from the JSON IPC payload cannot wrap the byte cast. +internal static class HonorificTitleColor +{ + internal static uint ResolveTitleAbgr(Vector3? color, Theme theme) + { + return color is { } c + ? ColourUtil.RgbaToAbgr(ColourUtil.Vector4ToRgba(new Vector4(c, 1f))) + : ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); + } +} diff --git a/HellionChat/Ui/Components/InputBar.cs b/HellionChat/Ui/Components/InputBar.cs new file mode 100644 index 0000000..55bb2d0 --- /dev/null +++ b/HellionChat/Ui/Components/InputBar.cs @@ -0,0 +1,820 @@ +using System.Numerics; +using System.Text; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Colors; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using HellionChat._Helpers; +using HellionChat.Code; +using HellionChat.GameFunctions; +using HellionChat.GameFunctions.Types; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Ui; +using HellionChat.Ui.StyleEngine; +using HellionChat.Util; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Components; + +// Bottom input row: channel pill, text field, quick buttons. Channel pill +// recolours by tab type — cyan accent for a normal channel, ember accent +// for a tell. Enter on the input field sends through ChatBox; messages +// that don't already start with a slash get the active channel's prefix +// prepended so a typed line in /fc reaches free-company chat instead of +// the current game-side channel. +internal sealed class InputBar +{ + public const float Height = 32f; + private const float PillHeight = 22f; + private const float PillPaddingX = 8f; + private const int BufferCapacity = 500; + private const float QuickButtonsReserve = 130f; + + private readonly SymbolPicker _symbolPicker; + private readonly FontManager _fonts; + private readonly ThemeRegistry _themes; + private readonly TokenResolver _resolver; + private readonly ILogger _logger; + private readonly Action _onOpenSettings; + private readonly CommandHelpWindow _commandHelpWindow; + + // Null in pop-out windows: the theme/tab quick-picker only belongs in the + // main window (1.5.4 had no pop-outs, and a tab jump from a channel-bound + // pop-out would be confusing). The main window's InputBar gets the instance. + private readonly ThemeQuickPicker? _themeQuickPicker; + + // Null in pop-outs (those have their own close button). Hides the main window. + private readonly Action? _onHideWindow; + + private string _pendingMessage = string.Empty; + private bool _isFocused; + private bool _wasInputTextHovered; + private bool? _isFocusedOverride; // Test-only; null = honour per-frame Draw() value. + + // UI-11 plugin-disclosure arm-and-hold: holds the buffer that armed the + // disclosure warning. null = not armed. Compared by value so an edit + // re-arms and a resend on the identical buffer goes through. 1.5.6 parity + // (ChatInputBar 1d3b429:27). + private string? _disclosureArmedBuffer; + + // Auto-translate popup state — lives here because the popup lifecycle is + // tightly coupled to the input callback and the pending message buffer. + private const string AutoCompleteId = "##hellion-at-complete"; + private AutoCompleteInfo? _autoCompleteInfo; + private bool _autoCompleteOpen; + private List? _autoCompleteList; + private bool _fixCursor; + private int _autoCompleteSelection; + private bool _autoCompleteShouldScroll; + + // Cursor restore position after popup commit; -1 = no pending restore. + // The main InputText sees the write inside its CallbackAlways branch on the + // next frame because ImGui only honours data.CursorPos writes from a callback. + private int _activatePos = -1; + + public bool Activate; + + public InputBar( + SymbolPicker symbolPicker, + FontManager fonts, + ThemeRegistry themes, + TokenResolver resolver, + ILogger logger, + Action onOpenSettings, + CommandHelpWindow commandHelpWindow, + ThemeQuickPicker? themeQuickPicker = null, + Action? onHideWindow = null + ) + { + _symbolPicker = symbolPicker; + _fonts = fonts; + _themes = themes; + _resolver = resolver; + _logger = logger; + _onOpenSettings = onOpenSettings; + _commandHelpWindow = commandHelpWindow; + _themeQuickPicker = themeQuickPicker; + _onHideWindow = onHideWindow; + } + + public string PendingMessage => _pendingMessage; + public int PendingLength => _pendingMessage.Length; + + // IsFocused respects the test override first so a SelfTest can pin focus + // state without racing against per-frame ImGui.IsItemFocused() in Draw(). + // Note: when MainWindow is closed, DrawInputField never runs, so + // _isFocused keeps the last value written by the previous draw pass. + // The consumer that actually pushes this state across the IPC boundary + // (TypingIpc.BuildState, see F3 Step 2) gates on Plugin.MainWindow.IsOpen + // itself, so the stale backing-field never leaks to subscribers. Mirroring + // the gate here would require an extra Plugin-backref in InputBar that the + // rest of the component doesn't need. + public bool IsFocused => _isFocusedOverride ?? _isFocused; + + // Sampled in DrawInputField() right after ImGui.InputText so the value + // reflects the text widget, not a later QuickButton item. + public bool WasInputTextHovered => _wasInputTextHovered; + + public void ClearBuffer() => _pendingMessage = string.Empty; + + // BufferCapacity is an ImGui UX limit, not a protocol constraint. We + // LogWarning + truncate/drop (matching v1.5.6's silent-overwrite semantics) + // so overflow is observable via /xllog without forcing try/catch at call-sites. + public void SetPendingMessage(string value) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + if (value.Length > BufferCapacity) + { + _logger.LogWarning( + "SetPendingMessage: value of length {Length} exceeds BufferCapacity ({Capacity}); truncating.", + value.Length, + BufferCapacity + ); + _pendingMessage = value[..BufferCapacity]; + } + else + { + _pendingMessage = value; + } + } + + // Null treated as empty here (matches IsNullOrEmpty guard); contrast with SetPendingMessage which throws to surface PayloadHandler call-site bugs early. + public void AppendPending(string suffix) + { + if (string.IsNullOrEmpty(suffix)) + return; + if (_pendingMessage.Length + suffix.Length > BufferCapacity) + { + _logger.LogWarning( + "AppendPending: appending {SuffixLength} chars would exceed BufferCapacity ({Capacity}); dropping suffix.", + suffix.Length, + BufferCapacity + ); + return; + } + _pendingMessage += suffix; + } + + public void Draw(Tab? activeTab) + { + if (!_fonts.FontsReady) + { + ImGui.Dummy(new Vector2(0, Height)); + return; + } + + var theme = _themes.Active; + var isTell = activeTab is { IsTempTab: true, TellTarget: { } target } && target.IsSet(); + var pillToken = isTell ? Token.AccentEmber : Token.AccentPrimary; + var pillRgba = _resolver.Resolve(pillToken, theme.Colors); + var pillAbgr = ColourUtil.RgbaToAbgr(pillRgba); + var pillTextAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); + + DrawChannelPill(activeTab, isTell, pillAbgr, pillTextAbgr); + ImGui.SameLine(); + DrawInputField(activeTab); + ImGui.SameLine(); + DrawQuickButtons(); + + // UI-11: yellow inline warning while a plugin-only-glyph message is + // armed-and-held (buffer unchanged since it armed). Renders on its own + // line below the input row. 1.5.6 parity (ChatInputBar 1d3b429:93-103). + if ( + Plugin.Config.NotifyPluginDisclosure + && _disclosureArmedBuffer is not null + && _pendingMessage == _disclosureArmedBuffer + ) + { + ImGui.TextColored( + ImGuiColors.DalamudYellow, + HellionStrings.ChatInput_PluginDisclosure_Warning + ); + } + + // SymbolPicker popup is rendered last so it can splice its fragment + // straight into the pending buffer. + var inserted = _symbolPicker.DrawAndConsume(); + if (inserted is not null && _pendingMessage.Length + inserted.Length <= BufferCapacity) + _pendingMessage += inserted; + + // Theme/tab quick-picker popup (main window only; null in pop-outs). + _themeQuickPicker?.Draw(); + + // Auto-translate popup runs after all other popups so the OpenPopup + // anchor lands on the InputText item we just drew. + DrawAutoCompletePopup(); + } + + private static string ResolvePillLabel(Tab? tab, bool isTell) + { + if (isTell && tab?.TellTarget is { } t && t.IsSet()) + return $"→ {t.Name}"; + + // CurrentChannel carries the runtime input state; Tab.Channel is the + // saved default and is null for most non-FC tabs, which produced + // the "—" placeholder users saw. + var current = tab?.CurrentChannel?.Channel ?? InputChannel.Invalid; + + // Privacy transparency: a game-side tell or reply writes {Channel=Tell, + // TellTarget} onto the active tab's CurrentChannel even on a NORMAL tab + // (Tab.TellTarget stays empty, so the isTell branch above is false). In + // that state BuildOutgoing's leg2/leg3 would route the next typed line as + // /tell to that partner — but the bare "Tell" label hid WHO. Mirror the + // exact leg2/leg3 source (current==Tell, TempTellTarget ?? TellTarget) AND + // the COMP-1 world-resolve gate, so the pill names the partner ONLY when a + // /tell would actually be built; an unresolvable world sends no /tell and + // falls through to the plain label below. Read-only — no routing effect. + // 1.5.6 showed the partner name here; this restores that transparency. + if (current == InputChannel.Tell) + { + // Mirror BuildOutgoing's exact target chain for the tell channel (leg1 + // Tab.TellTarget first, then leg2/leg3 CurrentChannel) so the pill names + // precisely who the next line would reach — no drift between shown and sent. + var ccTarget = + tab is not null && tab.TellTarget.IsSet() + ? tab.TellTarget + : tab?.CurrentChannel?.TempTellTarget ?? tab?.CurrentChannel?.TellTarget; + if (ccTarget is not null && ccTarget.IsSet()) + { + var world = ccTarget.ToWorldString(); + if (!string.IsNullOrEmpty(world)) + return $"→ {ccTarget.Name}@{world}"; + } + } + + if (current != InputChannel.Invalid) + return current.ToChatType().Name(); + + if (tab?.Channel is { } saved) + return saved.ToChatType().Name(); + + return "—"; + } + + private void DrawChannelPill(Tab? tab, bool isTell, uint pillAbgr, uint textAbgr) + { + var label = ResolvePillLabel(tab, isTell); + var labelSize = ImGui.CalcTextSize(label); + var width = labelSize.X + PillPaddingX * 2; + var origin = ImGui.GetCursorScreenPos(); + var dl = ImGui.GetWindowDrawList(); + var max = origin + new Vector2(width, PillHeight); + + dl.AddRectFilled(origin, max, pillAbgr, 6f); + dl.AddText(origin + new Vector2(PillPaddingX, 3f), textAbgr, label); + + // Hit area over the rendered pill so a click opens the channel + // picker. InvisibleButton both reserves the layout slot and gives + // the popup a stable anchor item. + ImGui.InvisibleButton("##hellion-pill", new Vector2(width, PillHeight)); + if (ImGui.IsItemClicked() && tab is not null) + ImGui.OpenPopup("##hellion-channel-picker"); + + DrawChannelPickerPopup(tab); + } + + private static void DrawChannelPickerPopup(Tab? tab) + { + if (!ImGui.BeginPopup("##hellion-channel-picker")) + return; + + try + { + if (tab is null || tab.SelectedChannels.Count == 0) + { + ImGui.TextDisabled("No channels"); + return; + } + + foreach (var chatType in tab.SelectedChannels.Keys) + { + if (chatType.ToInputChannel() is not { } input) + continue; + + var isCurrent = tab.CurrentChannel.Channel == input; + if (ImGui.Selectable(input.ToChatType().Name(), isCurrent)) + tab.CurrentChannel.SetChannel(input); + } + } + finally + { + ImGui.EndPopup(); + } + } + + private void DrawInputField(Tab? activeTab) + { + if (Activate) + { + ImGui.SetKeyboardFocusHere(); + Activate = false; + } + + ImGui.SetNextItemWidth(-QuickButtonsReserve); + if ( + ImGui.InputText( + "##hellion-input", + ref _pendingMessage, + BufferCapacity, + ImGuiInputTextFlags.EnterReturnsTrue + | ImGuiInputTextFlags.CallbackEdit + | ImGuiInputTextFlags.CallbackCompletion + | ImGuiInputTextFlags.CallbackAlways, + SlashCommandCallback + ) + ) + { + _commandHelpWindow.IsOpen = false; + TrySend(activeTab); + } + _isFocused = ImGui.IsItemFocused(); + _wasInputTextHovered = ImGui.IsItemHovered(); + } + + // Dispatches across three ImGui callback events: CallbackAlways (cursor + // restore after popup commit), CallbackCompletion (Tab opens the auto- + // translate picker), CallbackEdit (slash-command help window sync). + private int SlashCommandCallback(scoped ref ImGuiInputTextCallbackData data) + { + // Cursor restore after popup commit. _activatePos is set in + // DrawAutoCompletePopup to "behind the inserted token"; + // we replay it on the next CallbackAlways frame because ImGui only + // honours data.CursorPos writes from inside a callback. + if (data.EventFlag == ImGuiInputTextFlags.CallbackAlways) + { + if (_activatePos != -1) + { + data.CursorPos = _activatePos; + data.SelectionStart = data.SelectionEnd = _activatePos; + _activatePos = -1; + } + return 0; + } + + if (data.EventFlag == ImGuiInputTextFlags.CallbackCompletion) + { + // CursorPos is a BYTE offset into the UTF-8 buffer. We decode the + // prefix up to the cursor as a managed string so every offset in + // AutoCompleteInfo is a CHAR offset — _pendingMessage is a managed + // string and gets spliced via char-indices in DrawAutoCompletePopup. + // Mixing byte- and char-offsets crashes on multi-byte UTF-8 (CJK, + // emoji) before the cursor. + var prefix = Encoding.UTF8.GetString(data.BufTextSpan[..data.CursorPos]); + var spaceIdx = prefix.LastIndexOf(' '); + var wordStart = spaceIdx < 0 ? 0 : spaceIdx + 1; + var word = prefix[wordStart..]; + _autoCompleteInfo = new AutoCompleteInfo(word, wordStart, prefix.Length); + _autoCompleteOpen = true; + _autoCompleteSelection = 0; + return 0; + } + + // CallbackEdit (or any remaining event): v1.5.6 character-level slash + // detection keeps CommandHelpWindow in sync with what the user is + // typing without a per-frame poll. + _commandHelpWindow.IsOpen = false; + + var text = Encoding.UTF8.GetString(data.BufTextSpan); + if (!text.StartsWith('/')) + return 0; + + var slashSpaceIdx = text.IndexOf(' '); + var command = slashSpaceIdx > 0 ? text[..slashSpaceIdx] : text; + + // Keys in CommandManager.Commands include the leading slash. + if (AllCommands.TryGetValue(command, out var textCommand)) + _commandHelpWindow.UpdateContent(textCommand.Description); + else if ( + Plugin.CommandManager.Commands.TryGetValue(command, out var info) && info.ShowInHelp + ) + _commandHelpWindow.UpdateContent(info.HelpMessage); + + return 0; + } + + private void TrySend(Tab? activeTab) + { + var text = _pendingMessage.Trim(); + if (string.IsNullOrEmpty(text)) + return; + + // UI-11: plugin-disclosure arm-and-hold. Arm + scan on the RAW + // _pendingMessage (NOT the trimmed `text`) so the Draw warning gate + // (_pendingMessage == _disclosureArmedBuffer) matches byte-for-byte even + // when the buffer has leading/trailing whitespace. 1.5.6 armed/held/ + // warned on the raw buffer and only trimmed at SendChatBox; storing the + // trimmed value here would silently kill the warning for a padded buffer + // (the Draw gate compares the untrimmed _pendingMessage). Runs BEFORE the + // channel prefix + AutoTranslate.ReplaceWithPayload (the resolved + // macro carries its own non-ASCII bytes and would false-positive; + // whitespace is never a PUA codepoint, so scanning the raw buffer is + // equivalent for detection). First Enter on a buffer with a plugin-only + // PUA glyph arms + HOLDS (returns without sending, buffer kept); a second + // Enter on the same unchanged buffer sends; editing re-checks. 1.5.6 + // parity (ChatInputBar.SubmitCompact 1d3b429:108-118). + if ( + Plugin.Config.NotifyPluginDisclosure + && _disclosureArmedBuffer != _pendingMessage + && PluginDisclosureScanner.ContainsPrivateUseGlyph(_pendingMessage) + ) + { + _disclosureArmedBuffer = _pendingMessage; + return; + } + _disclosureArmedBuffer = null; + + // Route the trimmed buffer into the exact send string. BuildOutgoing is + // pure (no send, no field write) so the SelfTest can exercise the tell + // routing without firing a real chat line; the wasTell flag drives the + // post-send ResetTempChannel below. + var (toSend, wasTell) = BuildOutgoing(activeTab, text); + + try + { + // AutoTranslate produces binary SeString macro bytes; SendMessage(string) + // would run SanitiseText over them and destroy the payload encoding. + // SendMessageUnsafe bypasses ValidateMessage entirely, so we mirror its + // 500-byte guard manually. + var bytes = Encoding.UTF8.GetBytes(toSend); + AutoTranslate.ReplaceWithPayload(ref bytes); + if (bytes.Length > 500) + { + _logger.LogWarning( + "TrySend dropped: message exceeds 500 bytes ({Length}) after AT-resolve.", + bytes.Length + ); + return; + } + ChatBox.SendMessageUnsafe(bytes); + _pendingMessage = string.Empty; + + // 1.5.6 parity (1d3b429:ChatLogWindow.cs:1558): clear the temp channel + // after a tell so a one-off /tell doesn't stick to the tab. Tell-only, + // so Say/Party/FC stay untouched. A no-op in today's input-bar path + // (TempTellTarget is inert), kept for an eventual temp-channel revival. + if (wasTell) + activeTab?.CurrentChannel?.ResetTempChannel(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to send chat message ({Length} chars)", toSend.Length); + } + } + + // Pure routing: turns the trimmed buffer into the bytes-source string and + // reports whether it became a tell. No send, no field mutation — the + // ResetTempChannel side-effect lives in TrySend, gated by wasTell, so this + // stays exercisable from the SelfTest. Slash input is verbatim (the game + // parser owns /tell, /fc, …); everything else gets the channel prefix, + // except a tell tab, which needs the full "/tell name@world" because + // InputChannel.Tell.Prefix() is only "/t" and would drop the target. + private (string toSend, bool wasTell) BuildOutgoing(Tab? activeTab, string text) + { + if (text.StartsWith('/')) + return (text, false); + + var current = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid; + + // 1.5.6 tell-target chain (1d3b429:ChatLogWindow.cs:1543-1546). + TellTarget? target = null; + if (activeTab is not null && activeTab.TellTarget.IsSet()) + { + // leg1 — unconditional: a freshly spawned temp tab carries its target + // only here, with CurrentChannel still Invalid until a sidebar/top-bar + // click runs EnsureCurrentChannel. A current==Tell gate would miss it. + target = activeTab.TellTarget; + } + else if (current == InputChannel.Tell) + { + // leg2/leg3 — gated on Tell (CORR-1): CurrentChannel.TellTarget is NOT + // channel-bound. After a game-side tell, switching the pill to Say leaves + // the tell target standing (SetChannel only sets Channel), so without this + // gate a say line would silently go out as /tell — a privacy misfire. + target = + activeTab?.CurrentChannel?.TempTellTarget ?? activeTab?.CurrentChannel?.TellTarget; + } + + // One world lookup, reused by the gate and the string build (ToTargetString + // would resolve the sheet twice). The !IsNullOrEmpty(world) check is the + // COMP-1 guard: IsSet() only proves World > 0, not that the id resolves in + // the Lumina sheet. A miss yields an empty world, and "/tell Name@ text" is + // exactly what the game rejects with "you must add the World name". On a miss + // we fall through to the channel-prefix path. + var world = target?.ToWorldString(); + if (target != null && target.IsSet() && !string.IsNullOrEmpty(world)) + return ($"/tell {target.Name}@{world} {text}", true); + + return (current == InputChannel.Invalid ? text : $"{current.Prefix()} {text}", false); + } + + private void DrawQuickButtons() + { + using (_fonts.FontAwesome.Push()) + { + if (ImGui.Button(FontAwesomeIcon.SmileBeam.ToIconString())) + _symbolPicker.OpenPopup(); + if (ImGui.IsItemHovered()) + { + using (ImRaii.DefaultFont()) + ImGui.SetTooltip("Insert symbol"); + } + + if (_themeQuickPicker is not null) + { + ImGui.SameLine(); + if (ImGui.Button(FontAwesomeIcon.Palette.ToIconString())) + _themeQuickPicker.OpenPopup(); + if (ImGui.IsItemHovered()) + { + using (ImRaii.DefaultFont()) + ImGui.SetTooltip(HellionStrings.Settings_QuickPicker_Tooltip); + } + } + + ImGui.SameLine(); + if (ImGui.Button(FontAwesomeIcon.Cog.ToIconString())) + { + _onOpenSettings(); + } + if (ImGui.IsItemHovered()) + { + using (ImRaii.DefaultFont()) + ImGui.SetTooltip("Settings"); + } + + // Hides the window (1.5.6 UserHide). One-way — Enter brings it back. + // Main window only (pop-outs have their own close); last in the row. + if (Plugin.Config.ShowHideButton && _onHideWindow is not null) + { + ImGui.SameLine(); + if (ImGui.Button(FontAwesomeIcon.EyeSlash.ToIconString())) + _onHideWindow(); + if (ImGui.IsItemHovered()) + { + using (ImRaii.DefaultFont()) + ImGui.SetTooltip("Hide chat (Enter to bring back)"); + } + } + } + } + + // Test-only hook; do not call from production code. + internal void TestSetPendingMessageForSelfTest(string value) => _pendingMessage = value; + + // Test-only hook; do not call from production code. Pass null to release the + // override and let Draw()'s ImGui.IsItemFocused() result take over again. + internal void TestSetFocusedForSelfTest(bool? value) => _isFocusedOverride = value; + + // Test-only hook; do not call from production code. Drives the REAL TrySend + // arm path: with NotifyPluginDisclosure on and a PUA glyph in the buffer the + // first call arms and HOLDS (no send). Returns whether the buffer is armed. + // The caller asserts PendingMessage is unchanged (held) so a regressed wiring + // that fell through to ChatBox.SendMessageUnsafe is caught. + internal bool TestTryArmDisclosureForSelfTest(Tab? activeTab) + { + TrySend(activeTab); + return _disclosureArmedBuffer is not null; + } + + // Test-only hook; do not call from production code. Clears the armed buffer + // so a SelfTest leaves no residual arm state. + internal void TestResetDisclosureForSelfTest() => _disclosureArmedBuffer = null; + + // Test-only hook; do not call from production code. Exposes the pure routing + // so the tell SelfTest can assert the string + wasTell flag without ever + // reaching ChatBox.SendMessageUnsafe (no real chat line). + internal (string toSend, bool wasTell) TestBuildOutgoingForSelfTest( + Tab? activeTab, + string text + ) => BuildOutgoing(activeTab, text); + + // Test-only hook; do not call from production code. Exposes the pure pill-label + // resolution so the tell-transparency SelfTest can assert the partner name is + // shown in the stale-/reply-tell state. Static (ResolvePillLabel is static). + internal static string TestResolvePillLabelForSelfTest(Tab? tab, bool isTell) => + ResolvePillLabel(tab, isTell); + + private void DrawAutoCompletePopup() + { + if (_autoCompleteInfo == null) + return; + + // Match cache: rebuilt on every search-field edit below. Lazy init here + // covers the first frame after Tab opens the popup. + _autoCompleteList ??= AutoTranslate.Matching( + _autoCompleteInfo.ToComplete, + Plugin.Config.SortAutoTranslate + ); + + if (_autoCompleteOpen) + { + ImGui.OpenPopup(AutoCompleteId); + _autoCompleteOpen = false; + } + + ImGui.SetNextWindowSize(new Vector2(400, 300) * ImGuiHelpers.GlobalScale); + using var popup = ImRaii.Popup(AutoCompleteId); + if (!popup.Success) + { + // Popup just closed (Escape, click-outside, or commit). Schedule the + // main InputText to re-focus and restore the cursor to the end of + // the original word so the user can keep typing without manual repositioning. + if (_activatePos == -1) + _activatePos = _autoCompleteInfo.EndPos; + + _autoCompleteInfo = null; + _autoCompleteList = null; + Activate = true; + return; + } + + ImGui.SetNextItemWidth(-1); + if ( + ImGui.InputTextWithHint( + "##hellion-at-search", + Language.AutoTranslate_Search_Hint, + ref _autoCompleteInfo.ToComplete, + 256, + ImGuiInputTextFlags.CallbackAlways | ImGuiInputTextFlags.CallbackHistory, + AutoCompleteCallback + ) + ) + { + // User typed in the search field: refresh matches and reset selection. + _autoCompleteList = AutoTranslate.Matching( + _autoCompleteInfo.ToComplete, + Plugin.Config.SortAutoTranslate + ); + _autoCompleteSelection = 0; + _autoCompleteShouldScroll = true; + } + + // Ctrl+0..9 jump-pick: 1..9 maps to index 0..8, 0 maps to index 9 (top-row layout). + var selected = -1; + if (ImGui.IsItemActive() && ImGui.GetIO().KeyCtrl) + { + for (var i = 0; i < 10 && i < _autoCompleteList.Count; i++) + { + var num = (i + 1) % 10; + var key = ImGuiKey.Key0 + num; + var key2 = ImGuiKey.Keypad0 + num; + if (ImGui.IsKeyDown(key) || ImGui.IsKeyDown(key2)) + selected = i; + } + } + + if (ImGui.IsItemDeactivated()) + { + if (ImGui.IsKeyDown(ImGuiKey.Escape)) + { + ImGui.CloseCurrentPopup(); + return; + } + + var enter = ImGui.IsKeyDown(ImGuiKey.Enter) || ImGui.IsKeyDown(ImGuiKey.KeypadEnter); + if (_autoCompleteList.Count > 0 && enter) + selected = _autoCompleteSelection; + } + + // First-frame focus: hand keyboard focus back to the search field and + // ask AutoCompleteCallback to drop the caret at the end of the prefix. + if (ImGui.IsWindowAppearing()) + { + _fixCursor = true; + ImGui.SetKeyboardFocusHere(-1); + } + + using var child = ImRaii.Child( + "##hellion-at-list", + Vector2.Zero, + false, + ImGuiWindowFlags.HorizontalScrollbar + ); + if (!child.Success) + return; + + // ListClipper wrapper (Util/SearchSelector.cs) is IDisposable, so the + // using-statement frees the unmanaged ImGuiListClipper for us — without + // it the block would leak per render frame. + using var clipper = new ListClipper(_autoCompleteList.Count); + foreach (var i in clipper.Rows) + { + var entry = _autoCompleteList[i]; + var highlight = _autoCompleteSelection == i; + var clicked = + ImGui.Selectable($"{entry.Text}##{entry.Group}/{entry.Row}", highlight) + || selected == i; + + if (i < 10) + { + var button = (i + 1) % 10; + var text = string.Format(Language.AutoTranslate_Completion_Key, button); + var size = ImGui.CalcTextSize(text); + ImGui.SameLine(ImGui.GetContentRegionAvail().X - size.X); + using ( + ImRaii.PushColor( + ImGuiCol.Text, + ImGui.GetStyle().Colors[(int)ImGuiCol.TextDisabled] + ) + ) + ImGui.TextUnformatted(text); + } + + if (!clicked) + continue; + + // StartPos/EndPos are CHAR offsets — see SlashCommandCallback's + // CallbackCompletion branch for the byte→char conversion rationale. + var start = _autoCompleteInfo.StartPos; + var end = _autoCompleteInfo.EndPos; + var replacement = $""; + _pendingMessage = _pendingMessage[..start] + replacement + _pendingMessage[end..]; + ImGui.CloseCurrentPopup(); + Activate = true; + _activatePos = start + replacement.Length; + } + + if (!_autoCompleteShouldScroll) + return; + + _autoCompleteShouldScroll = false; + var selectedPos = + clipper.DisplayEnd > 0 + ? _autoCompleteSelection * ImGui.GetTextLineHeightWithSpacing() + : 0f; + ImGui.SetScrollY(selectedPos); + } + + private int AutoCompleteCallback(scoped ref ImGuiInputTextCallbackData data) + { + // Runs every frame because the search field sets CallbackAlways. First + // frame after IsWindowAppearing flips _fixCursor on so the caret lands + // at the end of the pre-filled prefix instead of position 0. + if (data.EventFlag == ImGuiInputTextFlags.CallbackAlways) + { + if (_fixCursor && _autoCompleteInfo != null) + { + data.CursorPos = _autoCompleteInfo.ToComplete.Length; + data.SelectionStart = data.SelectionEnd = data.CursorPos; + _fixCursor = false; + } + } + + if (_autoCompleteList == null || _autoCompleteList.Count == 0) + return 0; + + switch (data.EventKey) + { + case ImGuiKey.UpArrow: + _autoCompleteSelection = + _autoCompleteSelection == 0 + ? _autoCompleteList.Count - 1 + : _autoCompleteSelection - 1; + _autoCompleteShouldScroll = true; + return 1; + case ImGuiKey.DownArrow: + _autoCompleteSelection = + _autoCompleteSelection == _autoCompleteList.Count - 1 + ? 0 + : _autoCompleteSelection + 1; + _autoCompleteShouldScroll = true; + return 1; + default: + // Tab inside the popup cycles forward — CallbackHistory does + // not fire for Tab, so we sniff it via IsKeyPressed inside + // the CallbackAlways pass. + if (ImGui.IsKeyPressed(ImGuiKey.Tab)) + { + _autoCompleteSelection = (_autoCompleteSelection + 1) % _autoCompleteList.Count; + _autoCompleteShouldScroll = true; + return 1; + } + break; + } + + return 0; + } +} + +// DTO for an in-flight auto-translate completion. Lives as a companion type +// in this file because it is only consumed by InputBar (see v1.7.1 Fix #4 plan §2.4). +internal sealed class AutoCompleteInfo +{ + // ToComplete MUST be a mutable field (not an auto-property), because the + // popup's ImGui.InputTextWithHint(... ref _autoCompleteInfo.ToComplete, ...) + // call takes it as a ref-parameter. Auto-properties cannot be passed as + // ref-targets — would produce CS0206 at compile time. + internal string ToComplete; + internal int StartPos { get; } + internal int EndPos { get; } + + internal AutoCompleteInfo(string toComplete, int startPos, int endPos) + { + ToComplete = toComplete; + StartPos = startPos; + EndPos = endPos; + } +} diff --git a/HellionChat/Ui/Components/MessageList.cs b/HellionChat/Ui/Components/MessageList.cs new file mode 100644 index 0000000..a9c43ea --- /dev/null +++ b/HellionChat/Ui/Components/MessageList.cs @@ -0,0 +1,249 @@ +using System.Globalization; +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using HellionChat.Resources; +using HellionChat.Util; + +namespace HellionChat.Ui.Components; + +// Virtualised message list. Compact mode reuses ImGuiListClipper because +// rows have a constant line height; card mode falls back to a linear +// render with a per-message height cache and an IsItemVisible skip path +// so off-screen rows place a Dummy of the cached height rather than +// running the full render again. +internal sealed class MessageList +{ + private const float CompactRowHeight = 18f; + + private readonly FontManager _fonts; + private readonly ChunkRenderer _chunkRenderer; + + private PayloadHandler? _handler; + + // B3-5: scroll-to-bottom state. Per-instance, so pop-out windows (own + // MessageList instance, PluginHostFactory.cs:263-266) isolate automatically — + // the old 1.5.6 updateScrollState flag is NOT needed here. + private bool _scrolledUp; + private bool _scrollToBottomRequested; + + // §6.2: setter-injection breaks the PayloadHandler → MainWindow → MessageList → PayloadHandler 3-cycle. + // Wired by PayloadHandlerInitHostedService.StartAsync after both singletons exist. + internal void AttachPayloadHandler(PayloadHandler handler) + { + _handler = handler; + } + + public MessageList(FontManager fonts, ChunkRenderer chunkRenderer) + { + _fonts = fonts; + _chunkRenderer = chunkRenderer; + } + + // Deterministic and ImGui-free: encapsulates the snap decision AND the + // request reset, so the reset invariant is covered. Called by the real Draw. + internal bool ResolveSnapToBottom(bool pinnedToBottom) + { + var snap = pinnedToBottom || _scrollToBottomRequested; + _scrollToBottomRequested = false; + return snap; + } + + // SelfTest hook (B3-5 reset-invariant, REQUIRED — not optional). Lets + // ScrollSnapDecisionStep flip the request flag without a real click, so the + // post-snap reset can be asserted; without it only the OR branch is testable. + internal void RequestScrollToBottomForSelfTest() => _scrollToBottomRequested = true; + + public void Draw(Tab tab) + { + if (!_fonts.FontsReady) + { + ImGui.TextUnformatted("Loading fonts…"); + return; + } + + // No own ImRaii.Child here — MainWindow already wraps the message + // area in one. Nesting would give the window two stacked scrolls + // and a runaway content-height computation. + using var messages = tab.Messages.GetReadOnly(3); + var compact = Plugin.Config.UseCompactDensity; + + // Track whether the user was pinned to the bottom before this frame + // so newly arriving rows do not yank them up. The check runs against + // the parent child's scroll state, which is the one MainWindow owns. + var pinnedToBottom = ImGui.GetScrollY() >= ImGui.GetScrollMaxY() - 1f; + + if (compact) + DrawCompact(messages); + else + DrawCard(tab, messages); + + // B3-5: scroll values are frame-constant inside the child, so this + // reflects the current frame's state wherever it runs; kept after the + // render to mirror the 1.5.6 end-of-DrawMessageLog placement. + _scrolledUp = ImGui.GetScrollMaxY() - ImGui.GetScrollY() > 1f; + + if (ResolveSnapToBottom(pinnedToBottom)) + ImGui.SetScrollHereY(1f); + + DrawScrollToBottomBar(); + + // OpenPopup in Click() and BeginPopup here share the ##hellion-main-area scope -> Popup-ID matches. + _handler?.Draw(); + } + + // B3-5: Discord-style full-width bar pinned to the bottom edge of the + // visible region while the user is scrolled up. Geometry comes from window + // pos + size (visible region), never from the content flow: when scrolled + // up the visible bottom sits above the content bottom, so the + // InvisibleButton stays inside the existing content rect and cannot grow + // GetScrollMaxY(). Drawn on the WINDOW drawlist so the enclosing child + // clips it; submitted after every payload chunk so the button wins the + // hit-test and PostPayload clicks underneath do not double-fire. + private void DrawScrollToBottomBar() + { + if (!_scrolledUp) + return; + + var winPos = ImGui.GetWindowPos(); + var winSize = ImGui.GetWindowSize(); + var barHeight = ImGui.GetFrameHeight(); + // The bar only renders while content overflows, so the vertical + // scrollbar is always up — keep the bar clear of it. + var barWidth = winSize.X - ImGui.GetStyle().ScrollbarSize; + var barTopLeft = new Vector2(winPos.X, winPos.Y + winSize.Y - barHeight); + var barBottomRight = barTopLeft + new Vector2(barWidth, barHeight); + + var theme = Plugin.Instance.ThemeRegistry.Active; + var hovered = ImGui.IsMouseHoveringRect(barTopLeft, barBottomRight); + var fill = ColourUtil.RgbaToAbgr( + hovered ? theme.Colors.SurfaceHover : theme.Colors.Surface + ); + var rounding = 4f * ImGuiHelpers.GlobalScale; + var dl = ImGui.GetWindowDrawList(); + dl.AddRectFilled(barTopLeft, barBottomRight, fill, rounding); + dl.AddRect( + barTopLeft, + barBottomRight, + ColourUtil.RgbaToAbgr(theme.Colors.Border), + rounding + ); + + var label = HellionStrings.ChatLog_ScrollToBottom_Tooltip; + var textSize = ImGui.CalcTextSize(label); + var textPos = + barTopLeft + new Vector2((barWidth - textSize.X) / 2f, (barHeight - textSize.Y) / 2f); + dl.AddText(textPos, ColourUtil.RgbaToAbgr(theme.Colors.Accent), label); + + // Click target after the visuals; nothing advances the cursor past the + // button, so content height is identical with and without the bar. + ImGui.SetCursorScreenPos(barTopLeft); + ImGui.InvisibleButton("##scroll-to-bottom-bar", new Vector2(barWidth, barHeight)); + if (ImGui.IsItemClicked()) + _scrollToBottomRequested = true; + } + + private void DrawCompact(IReadOnlyList messages) + { + unsafe + { + var clipper = new ImGuiListClipperPtr(ImGuiNative.ImGuiListClipper()); + try + { + clipper.Begin(messages.Count, CompactRowHeight); + while (clipper.Step()) + { + for (var i = clipper.DisplayStart; i < clipper.DisplayEnd; i++) + DrawCompactRow(messages[i]); + } + clipper.End(); + } + finally + { + clipper.Destroy(); + } + } + } + + private void DrawCompactRow(Message message) + { + // B2-1/B2-2: render the sender through DrawChunks (the name-aware path + // that applies WorldSuffixMode/NameFormMode via ForDisplay), not as a + // flat SenderSource.TextValue string. message.Sender already carries the + // channel brackets/colon as ChunkSource.None wrappers (MessageManager + // .cs:300-314), so the separator is rendered by the chunks. 1.5.6 parity + // (ChatLogWindow.cs:1965: DrawChunks(message.Sender) + SameLine). + var timestamp = FormatTimestamp(message.Date); + if (message.Sender.Count > 0) + { + ImGui.TextUnformatted($"{timestamp} "); + ImGui.SameLine(0f, 0f); + _chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f); + ImGui.SameLine(0f, 0f); + } + else + { + ImGui.TextUnformatted(timestamp); + ImGui.SameLine(0f, 0f); + } + _chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f); + } + + private void DrawCard(Tab tab, IReadOnlyList messages) + { + var tabId = tab.Identifier; + for (var i = 0; i < messages.Count; i++) + { + var msg = messages[i]; + + // Cached row: place a Dummy of the known height and skip the + // full render path if the row is off-screen. Mirrors the + // v1.5.6 Card-Mode pattern in ChatLogWindow.DrawMessages. + msg.Height.TryGetValue(tabId, out var cachedHeight); + if (cachedHeight is float h) + { + var beforeDummy = ImGui.GetCursorPos(); + ImGui.Dummy(new Vector2(10f, h)); + var visible = ImGui.IsItemVisible(); + msg.IsVisible[tabId] = visible; + if (!visible) + continue; + ImGui.SetCursorPos(beforeDummy); + } + + var before = ImGui.GetCursorPosY(); + DrawCardRow(msg); + var after = ImGui.GetCursorPosY(); + msg.Height[tabId] = after - before; + } + } + + private void DrawCardRow(Message message) + { + // B2-1/B2-2: sender via DrawChunks (name-aware path), on its own line + // with content below — 1.5.6 card parity (ChatLogWindow.cs:1913, no + // SameLine after the sender). The 1.5.6 channel-colour push on the + // sender is deferred styling polish (masterplan §6 -> v1.9.0); plain + // text here. + var timestamp = FormatTimestamp(message.Date); + if (message.Sender.Count > 0) + { + ImGui.TextUnformatted($"{timestamp} "); + ImGui.SameLine(0f, 0f); + _chunkRenderer.DrawChunks(message.Sender, wrap: true, handler: _handler, lineWidth: 0f); + } + else + { + ImGui.TextUnformatted(timestamp); + } + _chunkRenderer.DrawChunks(message.Content, wrap: true, handler: _handler, lineWidth: 0f); + } + + private static string FormatTimestamp(DateTimeOffset date) + { + var local = date.ToLocalTime(); + return Plugin.Config.Use24HourClock + ? local.ToString("HH:mm", CultureInfo.InvariantCulture) + : local.ToString("h:mm tt", CultureInfo.InvariantCulture); + } +} diff --git a/HellionChat/Ui/Components/Settings/ChatColourPicker.cs b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs new file mode 100644 index 0000000..62f1e24 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ChatColourPicker.cs @@ -0,0 +1,242 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +// Restores the 1.5.6 chat-channel colour editor (1d3b429:Appearance.cs:189-243, +// 409-534): presets, the per-ChatType ColorEdit3 over Config.ChatColours (which +// ChunkRenderer consumes), reset/import-game-colour buttons, and the apply-banner +// that adopts the active theme's chatChannels. v1.6.0 saves live + refreshes cache. +internal sealed class ChatColourPicker +{ + private readonly Plugin _plugin; + private readonly ThemeRegistry _themes; + private string? _applyDismissedFor; + private string? _lastSeenSlug; + + public ChatColourPicker(Plugin plugin, ThemeRegistry themes) + { + _plugin = plugin; + _themes = themes; + } + + // Drawn directly under the theme picker (1.5.6 placement) so the adopt prompt + // sits next to the theme that triggered it, not at the bottom of the tab. + public void DrawThemeAdoptBanner() => DrawApplyBanner(_themes.Active); + + public void Draw() + { + if (!ImGui.CollapsingHeader(HellionStrings.Settings_Section_Colours)) + return; + + DrawPresetButtons(); + ImGui.TextDisabled(HellionStrings.Settings_Appearance_Colours_PresetsHint); + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + + if ( + ImGui.Checkbox( + Language.Options_ColorSelectedInputChannelButton_Name, + ref Plugin.Config.ColorSelectedInputChannelButton + ) + ) + { + _plugin.SaveConfig(); + } + ImGuiUtil.HelpMarker(Language.Options_ColorSelectedInputChannelButton_Description); + ImGui.Spacing(); + + // Discrete clicks (reset/import) persist at once. The ColorEdit3 drag only + // recolours live (Refresh, no disk write) and defers SaveConfig to release + // via IsItemDeactivatedAfterEdit, so dragging the colour wheel doesn't fire + // a full-config disk write every frame. + var commit = false; + var liveOnly = false; + foreach (var (_, types) in ChatTypeExt.SortOrder) + { + foreach (var type in types) + { + if ( + ImGuiUtil.IconButton( + FontAwesomeIcon.UndoAlt, + $"{type}", + Language.Options_ChatColours_Reset + ) + ) + { + Plugin.Config.ChatColours.Remove(type); + commit = true; + } + + ImGui.SameLine(); + + if ( + ImGuiUtil.IconButton( + FontAwesomeIcon.LongArrowAltDown, + $"{type}", + Language.Options_ChatColours_Import + ) + ) + { + var gameColour = _plugin.Functions.Chat.GetChannelColor(type); + Plugin.Config.ChatColours[type] = gameColour ?? type.DefaultColor() ?? 0; + commit = true; + } + + ImGui.SameLine(); + + var vec = Plugin.Config.ChatColours.TryGetValue(type, out var colour) + ? ColourUtil.RgbaToVector3(colour) + : ColourUtil.RgbaToVector3(type.DefaultColor() ?? 0); + if (ImGui.ColorEdit3(type.Name(), ref vec, ImGuiColorEditFlags.NoInputs)) + { + Plugin.Config.ChatColours[type] = ColourUtil.Vector3ToRgba(vec); + liveOnly = true; + } + if (ImGui.IsItemDeactivatedAfterEdit()) + commit = true; + } + } + + if (commit) + ApplyChatColourChange(); + else if (liveOnly) + GlobalParametersCache.Refresh(); + + ImGui.Spacing(); + } + + private void DrawPresetButtons() + { + var first = true; + foreach (var (_, preset) in ChatColourPresets.All) + { + if (!first) + ImGui.SameLine(); + first = false; + + var brand = preset.IsBrandPreset; + if (brand) + { + var border = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(255, 128, 200)); + var btn = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(74, 42, 106)); + ImGui.PushStyleColor(ImGuiCol.Border, new Vector4(border, 1f)); + ImGui.PushStyleColor(ImGuiCol.Button, new Vector4(btn, 1f)); + ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.5f); + } + + if (ImGui.Button(GetPresetLabel(preset))) + ApplyPreset(preset); + + if (brand) + { + ImGui.PopStyleVar(); + ImGui.PopStyleColor(2); + } + } + } + + private static string GetPresetLabel(ChatColourPreset preset) + { + var localized = HellionStrings.ResourceManager.GetString( + preset.LocalizationKey, + HellionStrings.Culture + ); + return string.IsNullOrEmpty(localized) ? preset.DisplayName : localized; + } + + private void ApplyPreset(ChatColourPreset preset) + { + foreach (var (channel, colour) in preset.Colours) + Plugin.Config.ChatColours[channel] = colour; + ApplyChatColourChange(); + } + + private void ApplyChatColourChange() + { + _plugin.SaveConfig(); + GlobalParametersCache.Refresh(); + } + + // Offers to adopt the active theme's chatChannels into Config.ChatColours when + // they differ; dismissable per theme slug (matches 1.5.6 banner behaviour). + private void DrawApplyBanner(Theme active) + { + // Clear the per-theme dismiss whenever the active theme changes, so leaving + // a theme and returning re-offers the prompt (1.5.6 reset this on every switch). + if (active.Slug != _lastSeenSlug) + { + _applyDismissedFor = null; + _lastSeenSlug = active.Slug; + } + + if (active.ChatColors is not { Channels.Count: > 0 } themeChatColors) + return; + if (_applyDismissedFor == active.Slug) + return; + + var alreadyMatching = themeChatColors.Channels.All(kvp => + Plugin.Config.ChatColours.TryGetValue(kvp.Key, out var current) && current == kvp.Value + ); + if (alreadyMatching) + return; + + ImGui.Spacing(); + var border = ColourUtil.RgbaToAbgr(active.Colors.Primary); + var bgFill = ColourUtil.RgbaToAbgr((active.Colors.Surface & 0xFFFFFF00u) | 0xCCu); + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + const float height = 64f; + var draw = ImGui.GetWindowDrawList(); + draw.AddRectFilled(origin, origin + new Vector2(width, height), bgFill, 4f); + draw.AddRect(origin, origin + new Vector2(width, height), border, 4f, ImDrawFlags.None, 1f); + draw.AddText( + origin + new Vector2(12f, 10f), + ColourUtil.RgbaToAbgr(active.Colors.TextPrimary), + HellionStrings.Settings_Themes_ApplyChatColors_Hint + ); + + using ( + ImRaii.PushColor( + ImGuiCol.Button, + new Vector4(ColourUtil.RgbaToVector3(active.Colors.Primary), 1f) + ) + ) + using ( + ImRaii.PushColor( + ImGuiCol.ButtonHovered, + new Vector4(ColourUtil.RgbaToVector3(active.Colors.PrimaryLight), 1f) + ) + ) + using ( + ImRaii.PushColor( + ImGuiCol.ButtonActive, + new Vector4(ColourUtil.RgbaToVector3(active.Colors.PrimaryDark), 1f) + ) + ) + { + ImGui.SetCursorScreenPos(origin + new Vector2(12f, 32f)); + if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Apply)) + { + foreach (var kvp in themeChatColors.Channels) + Plugin.Config.ChatColours[kvp.Key] = kvp.Value; + _applyDismissedFor = active.Slug; + ApplyChatColourChange(); + } + } + + ImGui.SameLine(); + if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Keep)) + _applyDismissedFor = active.Slug; + + ImGui.SetCursorScreenPos(origin + new Vector2(0f, height + 8f)); + ImGui.Spacing(); + } +} diff --git a/HellionChat/Ui/Components/Settings/ColorPicker.cs b/HellionChat/Ui/Components/Settings/ColorPicker.cs new file mode 100644 index 0000000..b991910 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ColorPicker.cs @@ -0,0 +1,282 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +internal sealed class ColorPicker +{ + private readonly ThemeRegistry _themes; + + public ColorPicker(ThemeRegistry themes) + { + _themes = themes; + } + + public void Draw() + { + if (_themes.EditingThemeBuffer is null) + { + DrawIdleState(); + return; + } + + DrawEditState(_themes.EditingThemeBuffer); + } + + private void DrawIdleState() + { + var active = _themes.Active; + ImGui.TextDisabled($"Active theme: {active.Name}"); + + // Fork built-ins before editing: Switch() prefers built-in slugs over custom + // files with the same slug, so an in-place edit would silently no-op. + if (active.IsBuiltIn) + { + if (ImGui.Button("Fork & Edit")) + { + ForkAndBeginEditing(active); + } + if (ImGui.IsItemHovered()) + { + ImGui.SetTooltip( + "Built-in themes cannot be edited in place. Fork creates a custom copy you can edit and save." + ); + } + } + else + { + if (ImGui.Button("Edit theme")) + { + _themes.BeginEditing(active); + } + } + } + + private void ForkAndBeginEditing(Theme source) + { + // 100 attempts is already absurd for one base slug; past that means the + // themes folder is broken, not a real user collision. + var newSlug = $"{source.Slug}_fork"; + var attempt = 2; + const int MaxAttempts = 100; + while (_themes.TryGet(newSlug, out _)) + { + if (attempt > MaxAttempts) + { + return; + } + newSlug = $"{source.Slug}_fork_{attempt++}"; + } + + var forked = source with + { + Slug = newSlug, + Name = $"{source.Name} (fork)", + IsBuiltIn = false, + }; + // The `with`-clone leaves AbgrCache empty (private setter outside primary ctor). + // OK here because EditingBuffer render path goes through TokenResolver.Resolve(token, buffer.Colors), + // not AbgrCache. Save triggers Switch() which recomputes for the active theme. + _themes.BeginEditing(forked); + } + + private void DrawEditState(Theme buffer) + { + ImGui.TextUnformatted($"Editing: {buffer.Name}"); + ImGui.Separator(); + + DrawSection( + "Surfaces", + buffer, + c => + new[] + { + ("WindowBg", c.WindowBg), + ("ChildBg", c.ChildBg), + ("FrameBg", c.FrameBg), + ("Surface", c.Surface), + ("SurfaceHover", c.SurfaceHover), + }, + (c, edits) => + c with + { + WindowBg = edits[0].color, + ChildBg = edits[1].color, + FrameBg = edits[2].color, + Surface = edits[3].color, + SurfaceHover = edits[4].color, + } + ); + + DrawSection( + "Borders", + buffer, + c => new[] { ("Border", c.Border) }, + (c, edits) => c with { Border = edits[0].color } + ); + + DrawSection( + "Text", + buffer, + c => + new[] + { + ("TextPrimary", c.TextPrimary), + ("TextMuted", c.TextMuted), + ("TextDim", c.TextDim), + }, + (c, edits) => + c with + { + TextPrimary = edits[0].color, + TextMuted = edits[1].color, + TextDim = edits[2].color, + } + ); + + DrawSection( + "Brand — Primary", + buffer, + c => + new[] + { + ("PrimaryDark", c.PrimaryDark), + ("Primary", c.Primary), + ("PrimaryLight", c.PrimaryLight), + ("PrimaryGlow", c.PrimaryGlow), + }, + (c, edits) => + c with + { + PrimaryDark = edits[0].color, + Primary = edits[1].color, + PrimaryLight = edits[2].color, + PrimaryGlow = edits[3].color, + } + ); + + DrawSection( + "Brand — Accent", + buffer, + c => + new[] + { + ("AccentDark", c.AccentDark), + ("Accent", c.Accent), + ("AccentLight", c.AccentLight), + }, + (c, edits) => + c with + { + AccentDark = edits[0].color, + Accent = edits[1].color, + AccentLight = edits[2].color, + } + ); + + DrawSection( + "Identity", + buffer, + c => new[] { ("Identity", c.Identity) }, + (c, edits) => c with { Identity = edits[0].color } + ); + + DrawSection( + "Status", + buffer, + c => + new[] + { + ("StatusSuccess", c.StatusSuccess), + ("StatusDanger", c.StatusDanger), + ("StatusWarning", c.StatusWarning), + ("StatusInfo", c.StatusInfo), + }, + (c, edits) => + c with + { + StatusSuccess = edits[0].color, + StatusDanger = edits[1].color, + StatusWarning = edits[2].color, + StatusInfo = edits[3].color, + } + ); + + ImGui.Separator(); + DrawActionButtons(buffer); + } + + private void DrawSection( + string title, + Theme buffer, + Func slots, + Func writeBack + ) + { + if (!ImGui.CollapsingHeader(title, ImGuiTreeNodeFlags.DefaultOpen)) + { + return; + } + + var current = slots(buffer.Colors); + var changed = false; + var working = current.ToArray(); + + for (var i = 0; i < working.Length; i++) + { + var (label, color) = working[i]; + var rgba = ColourUtil.RgbaToVector4(color); + if ( + ImGui.ColorEdit4( + $"{label}##slot-{title}-{i}", + ref rgba, + ImGuiColorEditFlags.AlphaBar | ImGuiColorEditFlags.AlphaPreviewHalf + ) + ) + { + working[i] = (label, ColourUtil.Vector4ToRgba(rgba)); + changed = true; + } + } + + if (changed) + { + var mutated = writeBack(buffer.Colors, working); + _themes.UpdateEditingBuffer(mutated); + } + } + + private void DrawActionButtons(Theme buffer) + { + if (ImGui.Button("Save")) + { + _themes.SaveEditingBuffer(out _); + } + ImGui.SameLine(); + if (ImGui.Button("Cancel")) + { + _themes.DiscardEditingBuffer(); + } + ImGui.SameLine(); + + // Reset is disabled during a fork edit: the active theme is still the built-in + // source until Save, so BeginEditing(Active) would throw away the fork's slug/name + // framing and effectively cancel the fork. Proper Reset-during-fork needs a tracked + // _editingSource field in ThemeRegistry (out of v1.7.0 scope). + var isForkBuffer = !buffer.IsBuiltIn && _themes.Active.Slug != buffer.Slug; + using (ImRaii.Disabled(isForkBuffer)) + { + if (ImGui.Button("Reset to source")) + { + _themes.BeginEditing(_themes.Active); + } + } + if (isForkBuffer && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + ImGui.SetTooltip("Reset is unavailable while editing a fork. Save or Cancel first."); + } + } +} diff --git a/HellionChat/Ui/Components/Settings/ContentArea.cs b/HellionChat/Ui/Components/Settings/ContentArea.cs new file mode 100644 index 0000000..1e7da68 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ContentArea.cs @@ -0,0 +1,18 @@ +using System.Numerics; +using Dalamud.Interface.Utility.Raii; + +namespace HellionChat.Ui.Components.Settings; + +internal sealed class ContentArea +{ + public void Draw(string activeTab, Action renderTab) + { + using var child = ImRaii.Child("##settings-content", new Vector2(0, 0), true); + if (!child.Success) + { + return; + } + + renderTab(activeTab); + } +} diff --git a/HellionChat/Ui/Components/Settings/FontsSection.cs b/HellionChat/Ui/Components/Settings/FontsSection.cs new file mode 100644 index 0000000..ce6c604 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/FontsSection.cs @@ -0,0 +1,206 @@ +using Dalamud; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.FontIdentifier; +using HellionChat.Resources; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +// Restores the 1.5.6 font-selection UI (1d3b429:Appearance.cs:249-405): pick the +// bundled Hellion font vs a custom global/Japanese/italic font, sizes, and extra +// glyph ranges. v1.6.0 saves live, so any change persists and rebuilds the atlas +// at once (RebuildDelegateFonts, unconditional — a face change keeps the size, so +// the size-gated IfChanged path would miss it). +internal sealed class FontsSection +{ + private readonly Plugin _plugin; + private readonly FontManager _fontManager; + + public FontsSection(Plugin plugin, FontManager fontManager) + { + _plugin = plugin; + _fontManager = fontManager; + } + + private void Apply() + { + _plugin.SaveConfig(); + _fontManager.RebuildDelegateFonts(); + } + + public void Draw() + { + if (!ImGui.CollapsingHeader(HellionStrings.Settings_Section_Fonts)) + return; + + // Readout so the user can see which font is actually active. + var active = + Plugin.Config.UseHellionFont ? "Hellion Inter (bundled)" + : Plugin.Config.FontsEnabled + ? $"Global: {Plugin.Config.GlobalFontV2.FontId.Family.EnglishName}" + : "FFXIV game font"; + ImGui.TextDisabled($"Active: {active}"); + ImGui.Spacing(); + + if ( + ImGui.Checkbox( + HellionStrings.Theme_UseHellionFont_Name, + ref Plugin.Config.UseHellionFont + ) + ) + { + if (Plugin.Config.UseHellionFont) + Plugin.Config.FontsEnabled = false; + Apply(); + } + ImGuiUtil.HelpMarker(HellionStrings.Theme_UseHellionFont_Description); + ImGui.Spacing(); + + if (Plugin.Config.UseHellionFont) + { + DrawSizeCombo(Language.Options_FontSize_Name, ref Plugin.Config.FontSizeV2); + ImGui.Spacing(); + } + else if (ImGui.Checkbox(Language.Options_FontsEnabled, ref Plugin.Config.FontsEnabled)) + { + Apply(); + } + + var unused = false; + if (!Plugin.Config.UseHellionFont && !Plugin.Config.FontsEnabled) + { + DrawSizeCombo(Language.Options_FontSize_Name, ref Plugin.Config.FontSizeV2); + } + else if (!Plugin.Config.UseHellionFont) + { + DrawFontChooser( + Language.Options_Font_Name, + Plugin.Config.GlobalFontV2, + false, + ref unused, + spec => Plugin.Config.GlobalFontV2 = spec, + () => Plugin.Config.GlobalFontV2 = DefaultFont(DalamudAsset.NotoSansCjkRegular), + "global" + ); + ImGuiUtil.HelpMarker( + string.Format(Language.Options_Font_Description, Plugin.PluginName) + ); + ImGuiUtil.WarningText(Language.Options_Font_Warning); + ImGui.Spacing(); + + DrawFontChooser( + Language.Options_JapaneseFont_Name, + Plugin.Config.JapaneseFontV2, + false, + ref unused, + spec => Plugin.Config.JapaneseFontV2 = spec, + () => Plugin.Config.JapaneseFontV2 = DefaultFont(DalamudAsset.NotoSansCjkMedium), + "japanese", + id => !id.LocaleNames?.ContainsKey("ja-jp") ?? false, + "いろはにほへと ちりぬるを" + ); + ImGuiUtil.HelpMarker( + string.Format(Language.Options_JapaneseFont_Description, Plugin.PluginName) + ); + ImGui.Spacing(); + + DrawFontChooser( + Language.Options_ItalicFont_Name, + Plugin.Config.ItalicFontV2, + true, + ref Plugin.Config.ItalicEnabled, + spec => Plugin.Config.ItalicFontV2 = spec, + () => + { + Plugin.Config.ItalicEnabled = false; + Plugin.Config.ItalicFontV2 = DefaultFont(DalamudAsset.NotoSansCjkRegular); + }, + "italic" + ); + ImGuiUtil.HelpMarker( + string.Format(Language.Options_Italic_Description, Plugin.PluginName) + ); + ImGui.Spacing(); + } + + // ExtraGlyphRanges stays reachable regardless of the font source so the + // user can verify/override the per-language auto-activation (v1.5.3 note). + ImGui.Spacing(); + if (ImGui.CollapsingHeader(Language.Options_ExtraGlyphs_Name)) + { + ImGuiUtil.HelpMarker( + string.Format(Language.Options_ExtraGlyphs_Description, Plugin.PluginName) + ); + + var range = (int)Plugin.Config.ExtraGlyphRanges; + var changed = false; + foreach (var extra in Enum.GetValues()) + changed |= ImGui.CheckboxFlags(extra.Name(), ref range, (int)extra); + + if (changed) + { + Plugin.Config.ExtraGlyphRanges = (ExtraGlyphRanges)range; + Apply(); + } + } + + DrawSizeCombo(Language.Options_SymbolsFontSize_Name, ref Plugin.Config.SymbolsFontSizeV2); + ImGuiUtil.HelpMarker(Language.Options_SymbolsFontSize_Description); + ImGui.Spacing(); + } + + private void DrawSizeCombo(string label, ref float size) + { + var before = size; + ImGuiUtil.FontSizeCombo(label, ref size); + if (!size.Equals(before)) + Apply(); + } + + private void DrawFontChooser( + string label, + SingleFontSpec font, + bool checkbox, + ref bool checkboxValue, + Action set, + Action reset, + string resetId, + Predicate? exclusion = null, + string? preview = null + ) + { + var prevCheckbox = checkboxValue; + var chooser = ImGuiUtil.FontChooser( + label, + font, + checkbox, + ref checkboxValue, + exclusion, + preview + ); + if (checkbox && checkboxValue != prevCheckbox) + Apply(); + + // The chooser dialog resolves on a worker thread; marshal the result back + // onto the framework thread before touching config + the font atlas. + chooser?.ResultTask.ContinueWith(r => + { + if (r.IsCompletedSuccessfully) + Plugin.Framework.Run(() => + { + set(r.Result); + Apply(); + }); + }); + + ImGui.SameLine(); + if (ImGui.Button($"Reset##{resetId}")) + { + reset(); + Apply(); + } + } + + private static SingleFontSpec DefaultFont(DalamudAsset asset) => + new() { FontId = new DalamudAssetFontAndFamilyId(asset), SizePt = 12.75f }; +} diff --git a/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs new file mode 100644 index 0000000..c058133 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/LivePreviewPanel.cs @@ -0,0 +1,342 @@ +using System.Numerics; +using System.Threading; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +internal sealed class LivePreviewPanel : IDisposable +{ + // Static counter for S5 reload-stress verification: after 10 reloads the + // counter must read 0 (plugin disabled) or 1 (plugin enabled). Anything + // higher signals a Dispose skip and a subscriber leak against ThemeRegistry. + internal static int InstanceCount; + + // Plan-mandated mock strings — international tester-ready, do not localise. + private const string MockSystem = "System: Connection established"; + private const string MockSay = "Say: Hello, world!"; + private const string MockTell = "Tell → Player: Hey, want to party?"; + private const string MockFc = "FC: Welcome aboard."; + + // Crown/cog render via the FontAwesome font (FontManager) so the preview + // matches the real header glyphs; the bundled text font has no crown glyph. + + private const float MiddleBandHeight = 220f; + private const float SidebarWidth = 70f; + + private readonly ThemeRegistry _themes; + private readonly TokenResolver _resolver; + private readonly FontManager _fonts; + + public LivePreviewPanel(ThemeRegistry themes, TokenResolver resolver, FontManager fonts) + { + _themes = themes; + _resolver = resolver; + _fonts = fonts; + _themes.OnEditingBufferChanged += OnBufferChanged; + Interlocked.Increment(ref InstanceCount); + } + + public void Dispose() + { + _themes.OnEditingBufferChanged -= OnBufferChanged; + Interlocked.Decrement(ref InstanceCount); + } + + private void OnBufferChanged() + { + // Visual repaint already runs per frame via Draw(); hook reserved for + // future telemetry or invalidation. Keep empty in v1.7.0. + } + + public void Draw() + { + using var child = ImRaii.Child("##settings-live-preview", new Vector2(280, 0), true); + if (!child.Success) + { + return; + } + + var theme = _themes.EditingThemeBuffer ?? _themes.Active; + + DrawBrandBar(theme); + DrawHonorificHeader(theme); + // Sidebar paints the left strip without advancing the cursor; the + // MessageList paints the right strip and reserves the full band. + DrawSidebar(theme); + DrawMessageList(theme); + DrawInputBar(theme); + DrawStatusBar(theme); + } + + private static void DrawBrandBar(Theme theme) + { + const float height = 24f; + var draw = ImGui.GetWindowDrawList(); + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + var max = new Vector2(origin.X + width, origin.Y + height); + + // Horizontal gradient split into three rects so all four primary + // slots (PrimaryDark/Primary/PrimaryLight/PrimaryGlow) drive the bar. + var pdAbgr = ColourUtil.RgbaToAbgr(theme.Colors.PrimaryDark); + var pAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Primary); + var plAbgr = ColourUtil.RgbaToAbgr(theme.Colors.PrimaryLight); + var pgAbgr = ColourUtil.RgbaToAbgr(theme.Colors.PrimaryGlow); + + var third = width / 3f; + draw.AddRectFilledMultiColor( + origin, + new Vector2(origin.X + third, max.Y), + pdAbgr, + pAbgr, + pAbgr, + pdAbgr + ); + draw.AddRectFilledMultiColor( + new Vector2(origin.X + third, origin.Y), + new Vector2(origin.X + 2 * third, max.Y), + pAbgr, + plAbgr, + plAbgr, + pAbgr + ); + draw.AddRectFilledMultiColor( + new Vector2(origin.X + 2 * third, origin.Y), + max, + plAbgr, + pgAbgr, + pgAbgr, + plAbgr + ); + + var label = "HellionChat"; + var textSize = ImGui.CalcTextSize(label); + var textPos = new Vector2( + origin.X + (width - textSize.X) * 0.5f, + origin.Y + (height - textSize.Y) * 0.5f + ); + draw.AddText(textPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), label); + + ImGui.Dummy(new Vector2(width, height)); + } + + private void DrawHonorificHeader(Theme theme) + { + const float height = 32f; + var draw = ImGui.GetWindowDrawList(); + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + + draw.AddLine( + origin, + new Vector2(origin.X + width, origin.Y), + ColourUtil.RgbaToAbgr(theme.Colors.Border), + 1f + ); + + var crownAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Identity); + // Shared fallback path with the real header (Weiche 3). The mock has no + // Honorific colour, so this resolves to TextPrimary today — visually + // unchanged — but both paths now share one resolver. No truncation here: + // the preview draws a fixed, centred "«Champion» Preview" string. + var textAbgr = HonorificTitleColor.ResolveTitleAbgr(null, theme); + var title = "«Champion» Preview"; + var crownGlyph = FontAwesomeIcon.Crown.ToIconString(); + + // Crown is a FontAwesome glyph (matches the real header); measure + draw + // it inside the FontAwesome push, the title stays in the default font. + float crownWidth; + using (_fonts.FontAwesome.Push()) + { + crownWidth = ImGui.CalcTextSize(crownGlyph).X; + } + var titleSize = ImGui.CalcTextSize(title); + var totalWidth = crownWidth + 4f + titleSize.X; + var startX = origin.X + (width - totalWidth) * 0.5f; + var y = origin.Y + (height - titleSize.Y) * 0.5f; + using (_fonts.FontAwesome.Push()) + { + draw.AddText(new Vector2(startX, y), crownAbgr, crownGlyph); + } + draw.AddText(new Vector2(startX + crownWidth + 4f, y), textAbgr, title); + + ImGui.Dummy(new Vector2(width, height)); + } + + private static void DrawSidebar(Theme theme) + { + // Paints the left strip of the horizontal middle band. MessageList + // consumes the cursor reservation for the full band height. + var draw = ImGui.GetWindowDrawList(); + var origin = ImGui.GetCursorScreenPos(); + var rowHeight = MiddleBandHeight / 3f; + var surface = ColourUtil.RgbaToAbgr(theme.Colors.Surface); + var surfaceHover = ColourUtil.RgbaToAbgr(theme.Colors.SurfaceHover); + var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); + var primaryAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Primary); + var accentAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Accent); + + ReadOnlySpan labels = ["Linkshell", "Tell", "FC"]; + for (var i = 0; i < 3; i++) + { + var rowMin = new Vector2(origin.X, origin.Y + i * rowHeight); + var rowMax = new Vector2(origin.X + SidebarWidth, rowMin.Y + rowHeight); + var bg = i == 1 ? surfaceHover : surface; + draw.AddRectFilled(rowMin, rowMax, bg); + + if (i == 0) + { + draw.AddRectFilled(rowMin, new Vector2(rowMin.X + 2f, rowMax.Y), primaryAbgr); + } + + var labelSize = ImGui.CalcTextSize(labels[i]); + var textPos = new Vector2(rowMin.X + 6f, rowMin.Y + (rowHeight - labelSize.Y) * 0.5f); + draw.AddText(textPos, textAbgr, labels[i]); + + if (i == 1) + { + // Tell row carries an unread-dot in Accent on the right. + var dotCenter = new Vector2(rowMax.X - 8f, rowMin.Y + rowHeight * 0.5f); + draw.AddRectFilled( + new Vector2(dotCenter.X - 2f, dotCenter.Y - 2f), + new Vector2(dotCenter.X + 2f, dotCenter.Y + 2f), + accentAbgr + ); + } + } + } + + private static void DrawMessageList(Theme theme) + { + var draw = ImGui.GetWindowDrawList(); + var origin = ImGui.GetCursorScreenPos(); + var totalWidth = ImGui.GetContentRegionAvail().X; + var listOrigin = new Vector2(origin.X + SidebarWidth, origin.Y); + var listWidth = totalWidth - SidebarWidth; + var max = new Vector2(listOrigin.X + listWidth, listOrigin.Y + MiddleBandHeight); + + draw.AddRectFilled(listOrigin, max, ColourUtil.RgbaToAbgr(theme.Colors.WindowBg)); + + var padMin = new Vector2(listOrigin.X + 2f, max.Y - 6f); + draw.AddRectFilled( + padMin, + new Vector2(max.X - 2f, max.Y - 2f), + ColourUtil.RgbaToAbgr(theme.Colors.FrameBg) + ); + + ReadOnlySpan<(string Text, uint Rgba)> rows = + [ + (MockSystem, theme.Colors.TextMuted), + (MockSay, theme.Colors.TextPrimary), + (MockTell, theme.Colors.StatusInfo), + (MockFc, theme.Colors.StatusSuccess), + ]; + + var lineHeight = ImGui.GetTextLineHeightWithSpacing(); + for (var i = 0; i < rows.Length; i++) + { + var pos = new Vector2(listOrigin.X + 6f, listOrigin.Y + 6f + i * lineHeight); + draw.AddText(pos, ColourUtil.RgbaToAbgr(rows[i].Rgba), rows[i].Text); + } + + draw.AddLine( + new Vector2(origin.X, max.Y - 1f), + new Vector2(origin.X + totalWidth, max.Y - 1f), + ColourUtil.RgbaToAbgr(theme.Colors.Border), + 1f + ); + + // Reserve the full middle-band height (sidebar overlays into the + // same vertical span and does not advance the cursor itself). + ImGui.Dummy(new Vector2(totalWidth, MiddleBandHeight)); + } + + private void DrawInputBar(Theme theme) + { + const float height = 24f; + const float pillWidth = 50f; + var draw = ImGui.GetWindowDrawList(); + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + var max = new Vector2(origin.X + width, origin.Y + height); + + draw.AddRectFilled(origin, max, ColourUtil.RgbaToAbgr(theme.Colors.FrameBg)); + + var pillMin = new Vector2(origin.X + 4f, origin.Y + 4f); + var pillMax = new Vector2(origin.X + pillWidth, max.Y - 4f); + draw.AddRectFilled(pillMin, pillMax, ColourUtil.RgbaToAbgr(theme.Colors.Primary), 6f); + + var pillLabel = "Say"; + var pillLabelSize = ImGui.CalcTextSize(pillLabel); + var pillTextPos = new Vector2( + pillMin.X + ((pillMax.X - pillMin.X) - pillLabelSize.X) * 0.5f, + pillMin.Y + ((pillMax.Y - pillMin.Y) - pillLabelSize.Y) * 0.5f + ); + draw.AddText(pillTextPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), pillLabel); + + var placeholder = "Type a message..."; + var phSize = ImGui.CalcTextSize(placeholder); + var phPos = new Vector2(pillMax.X + 6f, origin.Y + (height - phSize.Y) * 0.5f); + draw.AddText(phPos, ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), placeholder); + + var cogGlyph = FontAwesomeIcon.Cog.ToIconString(); + using (_fonts.FontAwesome.Push()) + { + var cogSize = ImGui.CalcTextSize(cogGlyph); + var cogPos = new Vector2( + max.X - cogSize.X - 6f, + origin.Y + (height - cogSize.Y) * 0.5f + ); + draw.AddText(cogPos, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted), cogGlyph); + } + + ImGui.Dummy(new Vector2(width, height)); + } + + private static void DrawStatusBar(Theme theme) + { + const float height = 20f; + const float iconSize = 8f; + const float iconGap = 6f; + var draw = ImGui.GetWindowDrawList(); + var origin = ImGui.GetCursorScreenPos(); + var width = ImGui.GetContentRegionAvail().X; + var max = new Vector2(origin.X + width, origin.Y + height); + + draw.AddRectFilled(origin, max, ColourUtil.RgbaToAbgr(theme.Colors.ChildBg)); + + ReadOnlySpan statusRgba = + [ + theme.Colors.StatusSuccess, + theme.Colors.StatusDanger, + theme.Colors.StatusWarning, + ]; + + var iconY = origin.Y + (height - iconSize) * 0.5f; + for (var i = 0; i < statusRgba.Length; i++) + { + var iconX = origin.X + 6f + i * (iconSize + iconGap); + draw.AddRectFilled( + new Vector2(iconX, iconY), + new Vector2(iconX + iconSize, iconY + iconSize), + ColourUtil.RgbaToAbgr(statusRgba[i]), + 2f + ); + } + + var label = "preview"; + var labelSize = ImGui.CalcTextSize(label); + var labelPos = new Vector2( + max.X - labelSize.X - 6f, + origin.Y + (height - labelSize.Y) * 0.5f + ); + draw.AddText(labelPos, ColourUtil.RgbaToAbgr(theme.Colors.TextDim), label); + + ImGui.Dummy(new Vector2(width, height)); + } +} diff --git a/HellionChat/Ui/Components/Settings/TabSidebar.cs b/HellionChat/Ui/Components/Settings/TabSidebar.cs new file mode 100644 index 0000000..e040c97 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/TabSidebar.cs @@ -0,0 +1,52 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; + +namespace HellionChat.Ui.Components.Settings; + +internal sealed class TabSidebar +{ + private readonly FontManager _fonts; + + public event Action? OnTabSelected; + public string ActiveTab { get; private set; } = "general"; + + public TabSidebar(FontManager fonts) + { + _fonts = fonts; + } + + public void Draw() + { + using var child = ImRaii.Child("##settings-tab-sidebar", new Vector2(170, 0), true); + if (!child.Success) + { + return; + } + + DrawEntry("general", FontAwesomeIcon.SlidersH, "General"); + DrawEntry("appearance", FontAwesomeIcon.Palette, "Appearance"); + DrawEntry("chat", FontAwesomeIcon.Comments, "Chat"); + DrawEntry("window", FontAwesomeIcon.WindowMaximize, "Window"); + DrawEntry("channels", FontAwesomeIcon.Hashtag, "Channels"); + DrawEntry("data-privacy", FontAwesomeIcon.Shield, "Data & Privacy"); + DrawEntry("about", FontAwesomeIcon.InfoCircle, "About"); + } + + private void DrawEntry(string id, FontAwesomeIcon icon, string label) + { + using (_fonts.FontAwesome.Push()) + { + ImGui.TextUnformatted(icon.ToIconString()); + } + ImGui.SameLine(); + + var selected = ActiveTab == id; + if (ImGui.Selectable($" {label}##tab-{id}", selected)) + { + ActiveTab = id; + OnTabSelected?.Invoke(id); + } + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs b/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs new file mode 100644 index 0000000..647429a --- /dev/null +++ b/HellionChat/Ui/Components/Settings/Tabs/AboutTab.cs @@ -0,0 +1,267 @@ +using System.Reflection; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using HellionChat.Branding; +using HellionChat.Integrations; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings.Tabs; + +internal sealed class AboutTab +{ + private readonly FontManager _fonts; + private readonly Plugin _plugin; + private readonly HonorificService _honorific; + private readonly ThemeRegistry _themes; + private readonly IPlatformUtil _platformUtil; + + // SelfTest observable — the status key the real render path resolved. + internal string? LastHonorificStatusKey { get; private set; } + + public AboutTab( + FontManager fonts, + Plugin plugin, + HonorificService honorific, + ThemeRegistry themes, + IPlatformUtil platformUtil + ) + { + _fonts = fonts; + _plugin = plugin; + _honorific = honorific; + _themes = themes; + _platformUtil = platformUtil; + } + + public void Draw() + { + // Reset the SelfTest observable each frame so a stale value from a prior + // real render can never let the integrations-status SelfTest pass falsely. + LastHonorificStatusKey = null; + DrawPluginInfo(); + DrawSectionHeader("Brand"); + DrawBrand(); + DrawSectionHeader("Links"); + DrawLinks(); + DrawSectionHeader("Integrations"); + DrawIntegrations(); + DrawSectionHeader("Credits"); + DrawCredits(); + DrawSectionHeader("License"); + DrawLicense(); + } + + // Dalamud.Bindings.ImGui does not expose ImGui.SeparatorText, so we use + // the Separator + TextUnformatted idiom the rest of the codebase uses. + private static void DrawSectionHeader(string title) + { + ImGui.Spacing(); + ImGui.Separator(); + ImGui.Spacing(); + ImGui.TextUnformatted(title); + } + + private static void DrawPluginInfo() + { + var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + ImGui.TextUnformatted("HellionChat"); + // Schema version pulled from Configuration.LatestVersion (single source of + // truth) so future schema bumps don't have to touch this string. + ImGui.TextDisabled($"Version {version} · Schema v{Configuration.LatestVersion}"); + } + + private void DrawBrand() + { + using (_fonts.FontAwesome.Push()) + { + // ImGui's PushStyleColor uint API is ABGR-native. 0xFF0C41C2u packs + // as A=FF B=0C G=41 R=C2 → RGB #C2410C (Forge-Bronze). No swap needed + // because the literal is already ABGR; theme-sourced uints from + // ThemeColors.* (RGBA) would need ColourUtil.RgbaToAbgr first. + ImGui.PushStyleColor(ImGuiCol.Text, 0xFF0C41C2u); + ImGui.TextUnformatted(FontAwesomeIcon.Hammer.ToIconString()); + ImGui.PopStyleColor(); + } + ImGui.SameLine(); + ImGui.TextUnformatted("by Hellion Online Media"); + ImGui.TextDisabled("Hellion Forge — Modding Division"); + } + + private void DrawLinks() + { + DrawLinkButton("Discord (Hellion Forge)", BrandingLinks.HellionForgeDiscordInvite); + DrawLinkButton("Gitea repository", BrandingLinks.HellionChatRepo); + DrawLinkButton("Custom repo manifest", BrandingLinks.HellionChatCustomRepoManifest); + } + + private void DrawIntegrations() + { + ImGui.TextWrapped(HellionStrings.Settings_Integrations_Intro); + ImGui.Spacing(); + + ImGui.TextUnformatted(HellionStrings.Settings_Integrations_Honorific_SectionHeader); + DrawHonorificStatus(); + DrawToggle( + HellionStrings.Settings_Integrations_Honorific_Toggle, + () => Plugin.Config.ShowHonorificTitleInHeader, + v => Plugin.Config.ShowHonorificTitleInHeader = v + ); + ImGui.TextDisabled(HellionStrings.Settings_Integrations_Honorific_ToggleHint); + DrawLinkButton( + HellionStrings.Settings_Integrations_Honorific_LinkRepo, + IntegrationLinks.HonorificRepo + ); + DrawLinkButton( + HellionStrings.Settings_Integrations_Honorific_LinkAuthor, + IntegrationLinks.HonorificAuthor + ); + + DrawComingSoon(); + DrawGotAnIdea(); + } + + private void DrawHonorificStatus() + { + var kind = HonorificStatus.Resolve(_honorific.IsAvailable, _honorific.DetectedApiVersion); + LastHonorificStatusKey = kind.ToString(); + var colors = _themes.Active.Colors; + + // Null-safety via the `is { } v` pattern, never `.Value` raw (spec SEC-2): + // the version is bound only on the arms that have it; the impossible + // Detected/Incompatible-without-version state falls through to default. + switch (kind) + { + case HonorificStatusKind.Detected when _honorific.DetectedApiVersion is { } v: + DrawStatusGlyph('●', colors.StatusSuccess); + ImGui.SameLine(); + ImGui.TextUnformatted( + string.Format( + HellionStrings.Settings_Integrations_Honorific_Status_Detected, + v.Major, + v.Minor + ) + ); + break; + case HonorificStatusKind.Incompatible when _honorific.DetectedApiVersion is { } iv: + DrawStatusGlyph('⚠', colors.StatusWarning); + ImGui.SameLine(); + ImGui.TextUnformatted( + string.Format( + HellionStrings.Settings_Integrations_Honorific_Status_Incompatible, + HonorificService.ExpectedApiMajor, + iv.Major, + iv.Minor + ) + ); + break; + default: + DrawStatusGlyph('○', colors.TextMuted); + ImGui.SameLine(); + ImGui.TextUnformatted( + HellionStrings.Settings_Integrations_Honorific_Status_NotInstalled + ); + break; + } + } + + private static void DrawStatusGlyph(char glyph, uint rgba) + { + ImGui.PushStyleColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(rgba)); + ImGui.TextUnformatted(glyph.ToString()); + ImGui.PopStyleColor(); + } + + private void DrawComingSoon() + { + ImGui.Spacing(); + ImGui.TextUnformatted(HellionStrings.Settings_Integrations_ComingSoon_SectionHeader); + ImGui.TextDisabled(HellionStrings.Settings_Integrations_ComingSoon_Intro); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Title, + HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Description + ); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_Notifications_Title, + HellionStrings.Settings_Integrations_ComingSoon_Notifications_Description + ); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_RPStatus_Title, + HellionStrings.Settings_Integrations_ComingSoon_RPStatus_Description + ); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_ExtraChat_Title, + HellionStrings.Settings_Integrations_ComingSoon_ExtraChat_Description + ); + DrawComingSoonItem( + HellionStrings.Settings_Integrations_ComingSoon_QuickDM_Title, + HellionStrings.Settings_Integrations_ComingSoon_QuickDM_Description + ); + } + + private void DrawComingSoonItem(string title, string description) + { + using (_fonts.FontAwesome.Push()) + { + ImGui.TextDisabled(FontAwesomeIcon.Hourglass.ToIconString()); + } + ImGui.SameLine(); + ImGui.TextUnformatted(title); + ImGui.TextDisabled(description); + } + + private void DrawGotAnIdea() + { + ImGui.Spacing(); + ImGui.TextUnformatted(HellionStrings.Settings_Integrations_GotAnIdea_SectionHeader); + ImGui.TextWrapped(HellionStrings.Settings_Integrations_GotAnIdea_Body); + if (ImGui.Button(HellionStrings.Settings_Integrations_GotAnIdea_LinkLabel)) + { + _platformUtil.OpenLink(BrandingLinks.HellionForgeDiscordInvite); + } + } + + private void DrawToggle(string label, Func get, Action set) + { + var current = get(); + if (ImGui.Checkbox(label, ref current)) + { + set(current); + _plugin.SaveConfig(); + } + } + + // URLs are exclusively hardcoded BrandingLinks/IntegrationLinks constants, + // validated to http/https at module-init. OpenLink centralises the browser + // open on an off-draw thread (it internally uses the same ShellExecute, so + // this is a consistency cleanup, not a security change). The standalone Copy + // button stays as the clipboard path. + private void DrawLinkButton(string label, string url) + { + if (ImGui.Button(label)) + { + _platformUtil.OpenLink(url); + } + ImGui.SameLine(); + if (ImGui.SmallButton($"Copy##{url}")) + { + ImGui.SetClipboardText(url); + } + } + + private static void DrawCredits() + { + ImGui.BulletText("ChatTwo — original maintainer Anna Clemens, GPL-3.0"); + ImGui.BulletText("Dalamud — goatcorp, AGPL-3.0"); + ImGui.BulletText("ImGui — Omar Cornut, MIT"); + ImGui.BulletText("FontAwesome — Fonticons, OFL-1.1"); + ImGui.BulletText("Inter — rsms, OFL-1.1"); + ImGui.BulletText("NotoSansCJK — Google, OFL-1.1"); + } + + private static void DrawLicense() + { + ImGui.TextUnformatted("GPL-3.0-or-later"); + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs b/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs new file mode 100644 index 0000000..b0035ed --- /dev/null +++ b/HellionChat/Ui/Components/Settings/Tabs/AppearanceTab.cs @@ -0,0 +1,58 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Ui.Components.Settings; + +namespace HellionChat.Ui.Components.Settings.Tabs; + +internal sealed class AppearanceTab +{ + private readonly ThemePicker _picker; + private readonly ColorPicker _color; + private readonly LivePreviewPanel _preview; + private readonly ThemeImportExportRow _importExport; + private readonly FontsSection _fonts; + private readonly ChatColourPicker _chatColours; + + public AppearanceTab( + ThemePicker picker, + ColorPicker color, + LivePreviewPanel preview, + ThemeImportExportRow importExport, + FontsSection fonts, + ChatColourPicker chatColours + ) + { + _picker = picker; + _color = color; + _preview = preview; + _importExport = importExport; + _fonts = fonts; + _chatColours = chatColours; + } + + public void Draw() + { + var availableX = ImGui.GetContentRegionAvail().X; + var leftWidth = MathF.Max(0, availableX - 290); + + using (var left = ImRaii.Child("##appearance-left", new Vector2(leftWidth, 0))) + { + if (left.Success) + { + _picker.Draw(); + _chatColours.DrawThemeAdoptBanner(); + ImGui.Spacing(); + _importExport.Draw(); + ImGui.Separator(); + _fonts.Draw(); + ImGui.Separator(); + _color.Draw(); + ImGui.Separator(); + _chatColours.Draw(); + } + } + ImGui.SameLine(); + _preview.Draw(); + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs new file mode 100644 index 0000000..b23658d --- /dev/null +++ b/HellionChat/Ui/Components/Settings/Tabs/ChannelsTab.cs @@ -0,0 +1,125 @@ +using Dalamud.Bindings.ImGui; + +namespace HellionChat.Ui.Components.Settings.Tabs; + +internal sealed class ChannelsTab +{ + private readonly Plugin _plugin; + + public ChannelsTab(Plugin plugin) + { + _plugin = plugin; + } + + public void Draw() + { + if (ImGui.CollapsingHeader("Tab management", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Enable auto-tell tabs", + () => Plugin.Config.EnableAutoTellTabs, + v => Plugin.Config.EnableAutoTellTabs = v + ); + DrawSliderInt( + "Auto-tell tabs limit", + () => Plugin.Config.AutoTellTabsLimit, + v => Plugin.Config.AutoTellTabsLimit = v, + 1, + 50 + ); + DrawToggle( + "Compact display", + () => Plugin.Config.AutoTellTabsCompactDisplay, + v => Plugin.Config.AutoTellTabsCompactDisplay = v + ); + DrawSliderInt( + "History preload", + () => Plugin.Config.AutoTellTabsHistoryPreload, + v => Plugin.Config.AutoTellTabsHistoryPreload = v, + 0, + 200 + ); + DrawToggle( + "Show greeted toggle", + () => Plugin.Config.AutoTellTabsShowGreetedToggle, + v => Plugin.Config.AutoTellTabsShowGreetedToggle = v + ); + DrawToggle( + "Open as popout", + () => Plugin.Config.AutoTellTabsOpenAsPopout, + v => Plugin.Config.AutoTellTabsOpenAsPopout = v + ); + } + + if (ImGui.CollapsingHeader("Tell auto-open mode", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawTellAutoOpenModeCombo(); + DrawToggle( + "Switch to the tab on every tell", + () => Plugin.Config.TellAutoOpenSwitchAlways, + v => Plugin.Config.TellAutoOpenSwitchAlways = v + ); + } + + if (ImGui.CollapsingHeader("Sidebar")) + { + // Range matches Sidebar.MinSidebarWidth/MaxSidebarWidth (40-300). The + // lower bound sits just above the 38px icon-only threshold; the + // on-disk default (44) and the 150px expanded reference both fit. + DrawSliderInt( + "Sidebar width", + () => Plugin.Config.SidebarWidth, + v => Plugin.Config.SidebarWidth = v, + 40, + 300 + ); + } + } + + private void DrawTellAutoOpenModeCombo() + { + var labels = new[] { "Off", "Sidebar", "Top tab", "Popout" }; + var values = Enum.GetValues(); + var current = Plugin.Config.TellAutoOpenMode; + var selected = 0; + for (var i = 0; i < values.Length; i++) + { + if (values[i] == current) + { + selected = i; + break; + } + } + + ImGui.SetNextItemWidth(220); + if (ImGui.Combo("Tell auto-open mode", ref selected, labels, labels.Length)) + { + if (selected >= 0 && selected < values.Length) + { + Plugin.Config.TellAutoOpenMode = values[selected]; + _plugin.SaveConfig(); + } + } + } + + private void DrawToggle(string label, Func get, Action set) + { + var current = get(); + if (ImGui.Checkbox(label, ref current)) + { + set(current); + _plugin.SaveConfig(); + } + } + + private void DrawSliderInt(string label, Func get, Action set, int min, int max) + { + var current = get(); + ImGui.SetNextItemWidth(200); + if (ImGui.SliderInt(label, ref current, min, max, "%d")) + { + set(current); + _plugin.SaveConfig(); + } + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs new file mode 100644 index 0000000..647d11a --- /dev/null +++ b/HellionChat/Ui/Components/Settings/Tabs/ChatTab.cs @@ -0,0 +1,194 @@ +using Dalamud.Bindings.ImGui; +using HellionChat.Code; +using HellionChat.Resources; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings.Tabs; + +internal sealed class ChatTab +{ + private readonly Plugin _plugin; + + public ChatTab(Plugin plugin) + { + _plugin = plugin; + } + + public void Draw() + { + if (ImGui.CollapsingHeader("Display modes", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Compact density (card vs compact)", + () => Plugin.Config.UseCompactDensity, + v => Plugin.Config.UseCompactDensity = v + ); + DrawToggle( + "More compact pretty mode", + () => Plugin.Config.MoreCompactPretty, + v => Plugin.Config.MoreCompactPretty = v + ); + DrawToggle( + "Prettier timestamps", + () => Plugin.Config.PrettierTimestamps, + v => Plugin.Config.PrettierTimestamps = v + ); + DrawToggle( + "Hide same timestamps", + () => Plugin.Config.HideSameTimestamps, + v => Plugin.Config.HideSameTimestamps = v + ); + DrawToggle( + "24-hour clock", + () => Plugin.Config.Use24HourClock, + v => Plugin.Config.Use24HourClock = v + ); + DrawWorldSuffixCombo(); + DrawNameFormCombo(); + } + + if (ImGui.CollapsingHeader("Channel filter")) + { + DrawToggle( + "Privacy filter enabled", + () => Plugin.Config.PrivacyFilterEnabled, + v => Plugin.Config.PrivacyFilterEnabled = v + ); + DrawPrivacyPersistChannels(); + } + + if (ImGui.CollapsingHeader("Command help")) + { + DrawCommandHelpSideCombo(); + } + + if (ImGui.CollapsingHeader("Plugin disclosure")) + { + DrawToggle( + HellionStrings.Settings_Chat_NotifyPluginDisclosure_Name, + () => Plugin.Config.NotifyPluginDisclosure, + v => Plugin.Config.NotifyPluginDisclosure = v + ); + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyPluginDisclosure_Description); + } + } + + private void DrawPrivacyPersistChannels() + { + // Enum.GetValues gives a stable order; HashSet membership is the source + // of truth, so we toggle via Add/Remove instead of mutating a copy. + ImGui.TextUnformatted("Persist channels:"); + foreach (var ct in Enum.GetValues()) + { + var label = ct.ToString(); + var present = Plugin.Config.PrivacyPersistChannels.Contains(ct); + if (ImGui.Checkbox($"{label}##persist-{label}", ref present)) + { + if (present) + { + Plugin.Config.PrivacyPersistChannels.Add(ct); + } + else + { + Plugin.Config.PrivacyPersistChannels.Remove(ct); + } + _plugin.SaveConfig(); + } + } + } + + private void DrawCommandHelpSideCombo() + { + var current = Plugin.Config.CommandHelpSide; + var values = Enum.GetValues(); + var labels = new string[values.Length]; + var selected = 0; + for (var i = 0; i < values.Length; i++) + { + labels[i] = values[i].Name(); + if (values[i] == current) + { + selected = i; + } + } + + ImGui.SetNextItemWidth(200); + if (ImGui.Combo("Command help side", ref selected, labels, labels.Length)) + { + Plugin.Config.CommandHelpSide = values[selected]; + _plugin.SaveConfig(); + } + } + + private void DrawWorldSuffixCombo() + { + var current = Plugin.Config.WorldSuffixMode; + var values = Enum.GetValues(); + var labels = new string[values.Length]; + var selected = 0; + for (var i = 0; i < values.Length; i++) + { + labels[i] = values[i].Name(); + if (values[i] == current) + { + selected = i; + } + } + + ImGui.SetNextItemWidth(200); + if ( + ImGui.Combo( + HellionStrings.Settings_Chat_WorldSuffix_Name, + ref selected, + labels, + labels.Length + ) + ) + { + Plugin.Config.WorldSuffixMode = values[selected]; + _plugin.SaveConfig(); + } + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_WorldSuffix_Description); + } + + private void DrawNameFormCombo() + { + var current = Plugin.Config.NameFormMode; + var values = Enum.GetValues(); + var labels = new string[values.Length]; + var selected = 0; + for (var i = 0; i < values.Length; i++) + { + labels[i] = values[i].Name(); + if (values[i] == current) + { + selected = i; + } + } + + ImGui.SetNextItemWidth(200); + if ( + ImGui.Combo( + HellionStrings.Settings_Chat_NameForm_Name, + ref selected, + labels, + labels.Length + ) + ) + { + Plugin.Config.NameFormMode = values[selected]; + _plugin.SaveConfig(); + } + ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NameForm_Description); + } + + private void DrawToggle(string label, Func get, Action set) + { + var current = get(); + if (ImGui.Checkbox(label, ref current)) + { + set(current); + _plugin.SaveConfig(); + } + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs new file mode 100644 index 0000000..043aa31 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/Tabs/DataPrivacyTab.cs @@ -0,0 +1,117 @@ +using Dalamud.Bindings.ImGui; +using HellionChat.Code; + +namespace HellionChat.Ui.Components.Settings.Tabs; + +internal sealed class DataPrivacyTab +{ + private readonly Plugin _plugin; + + public DataPrivacyTab(Plugin plugin) + { + _plugin = plugin; + } + + public void Draw() + { + if (ImGui.CollapsingHeader("Logging", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Print changelog on update", + () => Plugin.Config.PrintChangelog, + v => Plugin.Config.PrintChangelog = v + ); + DrawToggle( + "Enable retention sweep", + () => Plugin.Config.RetentionEnabled, + v => Plugin.Config.RetentionEnabled = v + ); + DrawSliderInt( + "Default retention (days)", + () => Plugin.Config.RetentionDefaultDays, + v => Plugin.Config.RetentionDefaultDays = v, + 1, + 365 + ); + + // RetentionLastRunAt defaults to MinValue on a fresh install, which + // would render as "0001-01-01 00:00" and look like a bug; the "Never" + // sentinel handles that. Disabling the sweep does NOT reset the + // timestamp — the historical last-run value is kept as informational + // carry-over until the next sweep updates it. + var lastRun = + Plugin.Config.RetentionLastRunAt == DateTimeOffset.MinValue + ? "Never" + : Plugin.Config.RetentionLastRunAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"); + ImGui.TextDisabled($"Last run: {lastRun}"); + } + + if (ImGui.CollapsingHeader("Privacy filter", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Enable privacy filter", + () => Plugin.Config.PrivacyFilterEnabled, + v => Plugin.Config.PrivacyFilterEnabled = v + ); + DrawPrivacyPersistChannelsGrid(); + DrawToggle( + "Persist unknown channels", + () => Plugin.Config.PrivacyPersistUnknownChannels, + v => Plugin.Config.PrivacyPersistUnknownChannels = v + ); + } + + if (ImGui.CollapsingHeader("Telemetry")) + { + // Read-only placeholder; no telemetry is wired in v1.7.0. Do not + // promote this to a toggle without an explicit Sub-Spec change. + ImGui.TextUnformatted("No telemetry is collected."); + } + } + + private void DrawPrivacyPersistChannelsGrid() + { + // HashSet: iterate Enum.GetValues() for stable + // display order (HashSet itself has none); toggle membership via + // Contains/Add/Remove. + ImGui.TextUnformatted("Persist channels:"); + foreach (var ct in Enum.GetValues()) + { + var label = ct.ToString(); + var present = Plugin.Config.PrivacyPersistChannels.Contains(ct); + if (ImGui.Checkbox($"{label}##privacy-persist-{label}", ref present)) + { + if (present) + { + Plugin.Config.PrivacyPersistChannels.Add(ct); + } + else + { + Plugin.Config.PrivacyPersistChannels.Remove(ct); + } + _plugin.SaveConfig(); + } + } + } + + private void DrawToggle(string label, Func get, Action set) + { + var current = get(); + if (ImGui.Checkbox(label, ref current)) + { + set(current); + _plugin.SaveConfig(); + } + } + + private void DrawSliderInt(string label, Func get, Action set, int min, int max) + { + var current = get(); + ImGui.SetNextItemWidth(200); + if (ImGui.SliderInt(label, ref current, min, max, "%d")) + { + set(current); + _plugin.SaveConfig(); + } + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs new file mode 100644 index 0000000..b82948f --- /dev/null +++ b/HellionChat/Ui/Components/Settings/Tabs/GeneralTab.cs @@ -0,0 +1,112 @@ +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings.Tabs; + +internal sealed class GeneralTab +{ + private readonly Plugin _plugin; + + public GeneralTab(Plugin plugin) + { + _plugin = plugin; + } + + public void Draw() + { + if (ImGui.CollapsingHeader("Behavior", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Reduce motion (no theme crossfade)", + () => Plugin.Config.ReduceMotion, + v => Plugin.Config.ReduceMotion = v + ); + DrawToggle( + "Print changelog on update", + () => Plugin.Config.PrintChangelog, + v => Plugin.Config.PrintChangelog = v + ); + } + + if (ImGui.CollapsingHeader("Keybinds", ImGuiTreeNodeFlags.DefaultOpen)) + { + ImGui.TextDisabled("Click a button, then press the key combination. Esc clears."); + DrawKeybind( + "Cycle to next chat tab", + "ChatTabForwardKeybind", + () => Plugin.Config.ChatTabForward, + v => Plugin.Config.ChatTabForward = v + ); + DrawKeybind( + "Cycle to previous chat tab", + "ChatTabBackwardKeybind", + () => Plugin.Config.ChatTabBackward, + v => Plugin.Config.ChatTabBackward = v + ); + } + + if (ImGui.CollapsingHeader("Notifications", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Show novice network", + () => Plugin.Config.ShowNoviceNetwork, + v => Plugin.Config.ShowNoviceNetwork = v + ); + } + + if (ImGui.CollapsingHeader("Volumes", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawSlider( + "Custom sound volume", + () => Plugin.Config.CustomSoundVolume, + v => Plugin.Config.CustomSoundVolume = v, + 0f, + 1f + ); + } + } + + private void DrawToggle(string label, Func get, Action set) + { + var current = get(); + if (ImGui.Checkbox(label, ref current)) + { + set(current); + _plugin.SaveConfig(); + } + } + + private void DrawSlider(string label, Func get, Action set, float min, float max) + { + var current = get(); + ImGui.SetNextItemWidth(200); + if (ImGui.SliderFloat(label, ref current, min, max, "%.2f")) + { + set(current); + _plugin.SaveConfig(); + } + } + + // Wires the already-present ImGuiUtil.KeybindInput capture widget (dead/unwired + // since the v1.6.0 rewrite) back into the settings, so ChatTabForward/Backward + // are bindable again. ConfigKeyBind is a reference type, so a capture (new + // instance) or an Esc-clear (null) changes the reference — persist only then. + private void DrawKeybind( + string label, + string id, + Func get, + Action set + ) + { + ImGui.TextUnformatted(label); + ImGui.SetNextItemWidth(-1); + var keybind = get(); + var before = keybind; + ImGuiUtil.KeybindInput(id, ref keybind); + if (!ReferenceEquals(before, keybind)) + { + set(keybind); + _plugin.SaveConfig(); + } + } +} diff --git a/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs new file mode 100644 index 0000000..e108486 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/Tabs/WindowTab.cs @@ -0,0 +1,154 @@ +using Dalamud.Bindings.ImGui; + +namespace HellionChat.Ui.Components.Settings.Tabs; + +internal sealed class WindowTab +{ + private readonly Plugin _plugin; + + public WindowTab(Plugin plugin) + { + _plugin = plugin; + } + + public void Draw() + { + if (ImGui.CollapsingHeader("Layout mode", ImGuiTreeNodeFlags.DefaultOpen)) + { + var mode = Plugin.Config.MainWindowLayoutMode; + if (ImGui.RadioButton("Sidebar", mode == MainWindowLayoutMode.Sidebar)) + { + Plugin.Config.MainWindowLayoutMode = MainWindowLayoutMode.Sidebar; + _plugin.SaveConfig(); + } + if (ImGui.RadioButton("Top tabs", mode == MainWindowLayoutMode.TopTabs)) + { + Plugin.Config.MainWindowLayoutMode = MainWindowLayoutMode.TopTabs; + _plugin.SaveConfig(); + } + } + + if (ImGui.CollapsingHeader("Window style", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Show title bar", + () => Plugin.Config.ShowTitleBar, + v => Plugin.Config.ShowTitleBar = v + ); + DrawToggle( + "Show title bar for pop-outs", + () => Plugin.Config.ShowPopOutTitleBar, + v => Plugin.Config.ShowPopOutTitleBar = v + ); + DrawToggle( + "Show hide button", + () => Plugin.Config.ShowHideButton, + v => Plugin.Config.ShowHideButton = v + ); + } + + if (ImGui.CollapsingHeader("Opacity", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawSlider( + "Window opacity", + () => Plugin.Config.WindowOpacity, + v => Plugin.Config.WindowOpacity = v, + 0.1f, + 1f + ); + DrawSlider( + "Inactive opacity", + () => Plugin.Config.WindowOpacityInactive, + v => Plugin.Config.WindowOpacityInactive = v, + 0.1f, + 1f + ); + } + + if (ImGui.CollapsingHeader("Resize behavior", ImGuiTreeNodeFlags.DefaultOpen)) + { + DrawToggle( + "Allow movement", + () => Plugin.Config.CanMove, + v => Plugin.Config.CanMove = v + ); + DrawToggle( + "Allow resize", + () => Plugin.Config.CanResize, + v => Plugin.Config.CanResize = v + ); + DrawSliderInt( + "Sidebar auto-switch threshold (px)", + () => Plugin.Config.SidebarAutoSwitchThresholdPx, + v => Plugin.Config.SidebarAutoSwitchThresholdPx = v, + 200, + 800 + ); + } + + if (ImGui.CollapsingHeader("Input preview")) + { + DrawPreviewPositionCombo(); + DrawToggle( + "Only show preview when typing", + () => Plugin.Config.OnlyPreviewIf, + v => Plugin.Config.OnlyPreviewIf = v + ); + } + } + + private void DrawPreviewPositionCombo() + { + var current = Plugin.Config.PreviewPosition; + var values = Enum.GetValues(); + var labels = new string[values.Length]; + var selected = 0; + for (var i = 0; i < values.Length; i++) + { + labels[i] = values[i].Name(); + if (values[i] == current) + { + selected = i; + } + } + + ImGui.SetNextItemWidth(200); + if (ImGui.Combo("Preview position", ref selected, labels, labels.Length)) + { + Plugin.Config.PreviewPosition = values[selected]; + _plugin.SaveConfig(); + } + } + + private void DrawToggle(string label, Func get, Action set) + { + var current = get(); + if (ImGui.Checkbox(label, ref current)) + { + set(current); + _plugin.SaveConfig(); + } + } + + private void DrawSlider(string label, Func get, Action set, float min, float max) + { + var current = get(); + ImGui.SetNextItemWidth(200); + if (ImGui.SliderFloat(label, ref current, min, max, "%.2f")) + { + set(current); + _plugin.SaveConfig(); + } + } + + private void DrawSliderInt(string label, Func get, Action set, int min, int max) + { + var current = get(); + ImGui.SetNextItemWidth(200); + if (ImGui.SliderInt(label, ref current, min, max, "%d")) + { + set(current); + _plugin.SaveConfig(); + } + } +} diff --git a/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs new file mode 100644 index 0000000..17a9743 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ThemeImportExportRow.cs @@ -0,0 +1,341 @@ +using System.Diagnostics; +using System.Security; +using Dalamud.Bindings.ImGui; +using HellionChat.Themes; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Components.Settings; + +internal sealed class ThemeImportExportRow +{ + private readonly ThemeRegistry _themes; + private readonly ILogger _logger; + private string _importPath = string.Empty; + + public ThemeImportExportRow(ThemeRegistry themes, ILogger logger) + { + _themes = themes; + _logger = logger; + } + + public void Draw() + { + if (ImGui.Button("Fork active theme")) + { + ForkActive(); + } + + ImGui.SameLine(); + if (ImGui.Button("Import theme file…")) + { + ImportFromPath(_importPath); + } + + ImGui.SameLine(); + if (ImGui.Button("Open themes folder")) + { + OpenThemesFolder(); + } + + ImGui.SameLine(); + if (ImGui.Button("Export active theme…")) + { + ExportActive(); + } + + ImGui.SetNextItemWidth(-1); + ImGui.InputTextWithHint( + "##theme-import-path", + "Path to JSON file (or drag-and-drop into the folder)", + ref _importPath, + 512 + ); + } + + private void ForkActive() + { + var source = _themes.Active; + var suffix = source.IsBuiltIn ? "fork" : "copy"; + var newSlug = $"{source.Slug}_{suffix}"; + var attempt = 2; + // Bounds the slug-collision search so a buggy TryGet (or a degenerate + // themes directory with 100+ collisions on the same prefix) cannot + // spin the UI thread indefinitely. 100 is the bound for a sensible + // user state — anything past that means the themes folder is broken, + // surfaces as a log warning instead of a frozen frame. + const int MaxAttempts = 100; + while (_themes.TryGet(newSlug, out _)) + { + if (attempt > MaxAttempts) + { + _logger.LogWarning( + "ForkActive aborted after {Max} slug-collision attempts on prefix {Prefix}", + MaxAttempts, + $"{source.Slug}_{suffix}" + ); + return; + } + newSlug = $"{source.Slug}_{suffix}_{attempt++}"; + } + + var forked = source with + { + Slug = newSlug, + Name = $"{source.Name} ({suffix})", + IsBuiltIn = false, + }; + _themes.BeginEditing(forked); + if (!_themes.SaveEditingBuffer(out var forkedPath)) + { + _logger.LogWarning( + "Fork-active save failed for slug {Slug}; editing buffer left untouched", + newSlug + ); + } + else + { + _logger.LogInformation("Forked active theme to {Path}", forkedPath); + } + } + + // 64 KiB cap so a typo or accidental 500MB-file drop does not pull + // arbitrary bytes into memory before the loader rejects it. HellionArctic + // serialises to ~3 KiB so 64 KiB is generous for legitimate themes. + private const int MaxImportFileBytes = 64 * 1024; + + private void ImportFromPath(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + _logger.LogWarning("Import skipped: file not found at {Path}", path); + return; + } + + // Extension guard — refuse non-.json before reading any bytes. + // Cost of a typo (or ~/.ssh/id_rsa dropped into the box) is bounded + // before file I/O happens. + if (!Path.GetExtension(path).Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Import skipped: not a .json file at {Path}", path); + return; + } + + // Size guard before ReadAllText so we never pull arbitrary bytes + // into memory or into logger exception messages. + long size; + try + { + size = new FileInfo(path).Length; + } + catch (Exception ex) + when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + _logger.LogWarning(ex, "Import skipped: cannot stat {Path}", path); + return; + } + if (size > MaxImportFileBytes) + { + _logger.LogWarning( + "Import skipped: file {Path} is {Size} bytes, exceeds {Max}", + path, + size, + MaxImportFileBytes + ); + return; + } + + try + { + var json = File.ReadAllText(path); + Theme? theme; + try + { + theme = ThemeJsonLoader.LoadFromString(json, _logger); + } + catch (FormatException) + { + // Swallow the FormatException body deliberately — the loader's + // message can include slices of the input (e.g. unterminated + // string contents). For non-JSON files chosen by mistake that + // could leak file content into the log. Path alone is enough + // to diagnose. + _logger.LogWarning("Import skipped: malformed theme JSON at {Path}", path); + return; + } + + if (theme is null) + { + _logger.LogWarning("Import skipped: invalid theme JSON at {Path}", path); + return; + } + + // Slug sanitisation BEFORE BeginEditing — SaveEditingBuffer would + // reject too, but rejecting here means an unsafe slug never enters + // the editing buffer. Shared helper ThemeRegistry.IsSafeThemeSlug + // keeps the rule set in sync with F1's save-side guard (see + // ThemeRegistry.IsSafeThemeSlug shared helper). + var importSlug = theme.Slug; + if (!ThemeRegistry.IsSafeThemeSlug(importSlug)) + { + _logger.LogWarning( + "Import skipped: theme at {Path} declares unsafe slug {Slug}", + path, + importSlug + ); + return; + } + + // Pragmatic deviation from §1.6 wording ("File.Copy into themes/"): + // BeginEditing+SaveEditingBuffer produces the same end-state and + // reuses the validated F1 save pipeline. Trade-off: destination + // filename becomes the theme's slug, not the original filename. + // + // Slug-collision handling: + // * Built-in collision -> rename to _imported. Switch() + // prefers built-ins (see ThemeRegistry.Switch built-in-first + // lookup), so a same-slug custom theme would persist on disk + // but never become active. + // * Custom-vs-custom collision -> rename to _imported_. + // Silent overwrite is dangerous: if the colliding custom theme + // is active right now, the import would replace the live theme + // with no undo path. Renaming preserves both files; the user + // can delete the imported copy from the themes folder if it + // was truly meant as an overwrite. + var importTheme = theme; + if (_themes.BuiltinSlugs.Contains(importTheme.Slug, StringComparer.OrdinalIgnoreCase)) + { + var renamedSlug = $"{importTheme.Slug}_imported"; + _logger.LogWarning( + "Imported theme slug {Slug} collides with a built-in; renaming to {Renamed}", + importTheme.Slug, + renamedSlug + ); + importTheme = importTheme with { Slug = renamedSlug }; + } + else if ( + _themes.TryGet(importTheme.Slug, out var existingCustom) + && !existingCustom.IsBuiltIn + ) + { + // Bounded slug-collision search (same rationale as ForkActive + // loop above): 100 attempts max so a pathological themes + // folder cannot spin the UI thread. + var baseSlug = $"{importTheme.Slug}_imported"; + var renamedSlug = baseSlug; + var attempt = 2; + const int MaxAttempts = 100; + while (_themes.TryGet(renamedSlug, out _)) + { + if (attempt > MaxAttempts) + { + _logger.LogWarning( + "Import aborted after {Max} custom-slug-collision attempts on prefix {Prefix}", + MaxAttempts, + baseSlug + ); + return; + } + renamedSlug = $"{baseSlug}_{attempt++}"; + } + _logger.LogWarning( + "Imported theme slug {Slug} collides with an existing custom theme; renaming to {Renamed}", + importTheme.Slug, + renamedSlug + ); + importTheme = importTheme with { Slug = renamedSlug }; + } + _themes.BeginEditing(importTheme); + if (!_themes.SaveEditingBuffer(out var importedPath)) + { + _logger.LogWarning( + "Import save failed for slug {Slug} from {Path}", + importTheme.Slug, + path + ); + } + else + { + _logger.LogInformation( + "Imported theme {Slug} from {Path} to {DestPath}", + importTheme.Slug, + path, + importedPath + ); + } + } + catch (IOException ex) + { + _logger.LogWarning(ex, "I/O error importing theme from {Path}", path); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Access denied importing theme from {Path}", path); + } + } + + // dir is sourced from ThemeRegistry.CustomThemesDir, built once in the + // registry ctor from a plugin-managed config path — never from user + // input. Process.Start with UseShellExecute=true is safe under that + // constraint. If a future cycle ever feeds user-supplied path here + // (custom-themes-dir override UI, drag-and-drop folder picker), validate + // it stays inside the plugin's config root BEFORE Process.Start + // (Path.GetFullPath comparison analogous to ThemeRegistry.SaveEditingBuffer's + // path-escape guard). Without that, a poisoned config could point at any + // directory on disk. + private void OpenThemesFolder() + { + var dir = _themes.CustomThemesDir; + if (string.IsNullOrEmpty(dir)) + { + return; + } + + try + { + Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true }); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not open themes folder {Dir}", dir); + } + } + + private void ExportActive() + { + // Capture the active theme now, not in the async dialog callback — the user + // could switch themes while the dialog is open. + var theme = _themes.Active; + var defaultName = $"{theme.Slug}.json"; + + Plugin.FileDialogManager.SaveFileDialog( + "Export theme", + ".json", + defaultName, + ".json", + (ok, path) => + { + if (ok) + { + ExportTo(theme, path); + } + }, + null, + isModal: true + ); + } + + private void ExportTo(Theme theme, string path) + { + try + { + var json = ThemeJsonWriter.Serialize(theme); + File.WriteAllText(path, json); + _logger.LogInformation("Exported theme {Slug} to {Path}", theme.Slug, path); + } + catch (Exception ex) + when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + _logger.LogWarning(ex, "Theme export to {Path} failed", path); + } + } +} diff --git a/HellionChat/Ui/SettingsTabs/ThemeMockup.cs b/HellionChat/Ui/Components/Settings/ThemeMockup.cs similarity index 85% rename from HellionChat/Ui/SettingsTabs/ThemeMockup.cs rename to HellionChat/Ui/Components/Settings/ThemeMockup.cs index f81798f..92c3d66 100644 --- a/HellionChat/Ui/SettingsTabs/ThemeMockup.cs +++ b/HellionChat/Ui/Components/Settings/ThemeMockup.cs @@ -3,18 +3,17 @@ using Dalamud.Bindings.ImGui; using HellionChat.Themes; using HellionChat.Util; -namespace HellionChat.Ui.SettingsTabs; +namespace HellionChat.Ui.Components.Settings; +// Mini chat-window mockup drawn straight into the WindowDrawList (restored from +// 1.5.6 ThemeMockup). No textures, no per-frame allocations — pure rect/text. internal static class ThemeMockup { - // Mini chat window mockup drawn directly into the WindowDrawList. - // No textures, no per-frame allocations — pure AddRectFilled/AddText. public static void Draw(Vector2 origin, Vector2 size, Theme theme) { var draw = ImGui.GetWindowDrawList(); var c = theme.Colors; - // Window background draw.AddRectFilled( origin, origin + size, @@ -22,7 +21,6 @@ internal static class ThemeMockup theme.Layout.WindowRounding ); - // Title bar var titleHeight = 14f; draw.AddRectFilled( origin, @@ -31,7 +29,6 @@ internal static class ThemeMockup theme.Layout.WindowRounding ); - // Tab bar (3 tabs) var tabY = origin.Y + titleHeight + 4f; var tabHeight = 12f; for (var i = 0; i < 3; i++) @@ -45,7 +42,7 @@ internal static class ThemeMockup theme.Layout.TabRounding ); - if (i == 0) // active pill + if (i == 0) { draw.AddRectFilled( new Vector2(tabX, tabY + tabHeight - 2f), @@ -55,7 +52,6 @@ internal static class ThemeMockup } } - // Message card row var rowY = tabY + tabHeight + 6f; var rowHeight = 18f; draw.AddRectFilled( @@ -65,7 +61,6 @@ internal static class ThemeMockup 2f ); - // Accent button (bottom right) var btnW = 28f; var btnH = 10f; var btnX = origin.X + size.X - btnW - 6f; @@ -77,7 +72,6 @@ internal static class ThemeMockup theme.Layout.FrameRounding ); - // Mockup border draw.AddRect( origin, origin + size, diff --git a/HellionChat/Ui/Components/Settings/ThemePicker.cs b/HellionChat/Ui/Components/Settings/ThemePicker.cs new file mode 100644 index 0000000..eab7a23 --- /dev/null +++ b/HellionChat/Ui/Components/Settings/ThemePicker.cs @@ -0,0 +1,176 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.Components.Settings; + +internal sealed class ThemePicker +{ + private static readonly (string Category, string[] Slugs, bool DefaultExpanded)[] CategoryMap = + { + ( + "Hellion Brand", + new[] { "hellion-arctic", "hellion-spectrum", "forge-merchantman" }, + true + ), + ( + "Cool", + new[] { "night-blue", "event-horizon", "indigo-violet", "crystal-nocturne" }, + false + ), + ("Natural", new[] { "mint-grove" }, false), + ("Classic", new[] { "chat2-classic" }, false), + ("Retro", new[] { "synthwave-sunset" }, false), + }; + + // T2 ThemePickerCategoryStep diffs this against ThemeRegistry.BuiltinSlugs + // to enforce coverage. Kept on the static map so the test does not pierce instance state. + internal static IEnumerable CategoryMapSlugs => CategoryMap.SelectMany(c => c.Slugs); + + private const float CardHeight = 132f; + + private readonly ThemeRegistry _themes; + private readonly Plugin _plugin; + + public ThemePicker(ThemeRegistry themes, Plugin plugin) + { + _themes = themes; + _plugin = plugin; + } + + public void Draw() + { + var locked = _themes.EditingThemeBuffer is not null; + + using (ImRaii.Disabled(locked)) + { + foreach (var (category, slugs, defaultExpanded) in CategoryMap) + { + var flags = defaultExpanded + ? ImGuiTreeNodeFlags.DefaultOpen + : ImGuiTreeNodeFlags.None; + if (ImGui.CollapsingHeader(category, flags)) + { + DrawThemeGrid(Resolve(slugs)); + } + } + + // Restore (1.5.6 Appearance.cs:79-88): list custom themes so forked/ + // imported themes are selectable, not just built-ins. + var customs = _themes.AllCustom().ToList(); + if (customs.Count > 0) + { + if ( + ImGui.CollapsingHeader( + $"Custom ({customs.Count})", + ImGuiTreeNodeFlags.DefaultOpen + ) + ) + { + DrawThemeGrid(customs); + } + } + } + + if (locked && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled)) + { + ImGui.SetTooltip("Save or discard your edits first"); + } + } + + private IEnumerable Resolve(IEnumerable slugs) + { + foreach (var slug in slugs) + if (_themes.TryGet(slug, out var theme)) + yield return theme; + } + + // Grid of theme cards, each carrying a mini chat mockup (restored from 1.5.6 + // DrawThemeGrid + ThemeMockup). Column count adapts to the available width. + private void DrawThemeGrid(IEnumerable themes) + { + var list = themes.ToList(); + if (list.Count == 0) + return; + + var avail = ImGui.GetContentRegionAvail().X; + var columns = avail >= 460f ? 2 : 1; + var cardWidth = columns > 1 ? (avail - (columns - 1) * 8f) / columns : avail; + + for (var i = 0; i < list.Count; i++) + { + DrawThemeCard(list[i], cardWidth, CardHeight); + if ((i + 1) % columns != 0 && i != list.Count - 1) + ImGui.SameLine(); + } + } + + private void DrawThemeCard(Theme theme, float w, float h) + { + ImGui.BeginGroup(); + + var isActive = string.Equals( + theme.Slug, + _themes.Active.Slug, + StringComparison.OrdinalIgnoreCase + ); + var origin = ImGui.GetCursorScreenPos(); + var clicked = ImGui.InvisibleButton($"##theme-card-{theme.Slug}", new Vector2(w, h)); + var hovered = ImGui.IsItemHovered(); + + var draw = ImGui.GetWindowDrawList(); + draw.AddRectFilled( + origin, + origin + new Vector2(w, h), + ColourUtil.RgbaToAbgr(theme.Colors.WindowBg | 0xFFu), + 4f + ); + + if (isActive) + { + draw.AddRect( + origin, + origin + new Vector2(w, h), + ColourUtil.RgbaToAbgr(theme.Colors.Primary), + 4f, + ImDrawFlags.None, + 2f + ); + } + else if (hovered) + { + draw.AddRect( + origin, + origin + new Vector2(w, h), + ColourUtil.RgbaToAbgr(theme.Colors.PrimaryLight & 0xFFFFFF99u), + 4f, + ImDrawFlags.None, + 1f + ); + } + + ThemeMockup.Draw(origin + new Vector2(12f, 12f), new Vector2(w - 24f, 60f), theme); + + draw.AddText( + origin + new Vector2(12f, 80f), + ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary), + theme.Name + ); + draw.AddText( + origin + new Vector2(12f, 100f), + ColourUtil.RgbaToAbgr(theme.Colors.TextMuted), + theme.Author + ); + + ImGui.EndGroup(); + + if (clicked) + { + _themes.Switch(theme.Slug); + Plugin.Config.Theme = theme.Slug; + _plugin.SaveConfig(); + } + } +} diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs new file mode 100644 index 0000000..3bacbee --- /dev/null +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -0,0 +1,427 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Code; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Ui.StyleEngine; +using HellionChat.Util; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Components; + +// Channel-list panel pinned to the left of the chat window. Auto-switches +// between an icon-only column (38px) and an expanded column (Config.SidebarWidth) once +// the outer window crosses Config.SidebarAutoSwitchThresholdPx. The +// pop-out affordance (hover button + right-click menu) routes through the +// injected ChannelPopoutPool via TryOpen, which reserves a slot and binds +// the tab to a pre-allocated pop-out window. +internal sealed class Sidebar +{ + public const float IconOnlyWidth = 38f; + + // B1-3a: expanded sidebar width is user-configurable (Config.SidebarWidth), + // clamped to these bounds (matches the ChannelsTab slider range). Replaces + // the old fixed 150px ExpandedWidth constant. + public const float MinSidebarWidth = 40f; + public const float MaxSidebarWidth = 300f; + + private const float RowHeight = 32f; + private const float PopOutHitWidth = 22f; + private const float GreetedHitWidth = 22f; + + // B3-2 render observability: counts greeted glyphs actually drawn this frame. + // Incremented ONLY in the real glyph branch in DrawRow; reset at Draw start. + // The SelfTest reads it after driving the real Draw — no dead service roundtrip. + internal int LastRenderedGreetedGlyphCount; + internal int LastRenderedUnreadDotCount; + + // B3-4 render observability: section headers actually drawn this frame. + // Incremented only in the real header branch; reset at Draw start. + internal int LastDrawnSectionHeaderCount; + + // Inline mirror of the old TabIconMapping table so the Ui layer carries + // its own glyph lookup once the standalone file is removed. + private static readonly Dictionary IconByName = new( + StringComparer.OrdinalIgnoreCase + ) + { + ["comment"] = FontAwesomeIcon.Comment, + ["comments"] = FontAwesomeIcon.Comments, + ["cog"] = FontAwesomeIcon.Cog, + ["users"] = FontAwesomeIcon.Users, + ["user-friends"] = FontAwesomeIcon.UserFriends, + ["link"] = FontAwesomeIcon.Link, + ["envelope"] = FontAwesomeIcon.Envelope, + ["clock"] = FontAwesomeIcon.Clock, + ["hashtag"] = FontAwesomeIcon.Hashtag, + ["star"] = FontAwesomeIcon.Star, + ["heart"] = FontAwesomeIcon.Heart, + ["bell"] = FontAwesomeIcon.Bell, + ["bookmark"] = FontAwesomeIcon.Bookmark, + ["flag"] = FontAwesomeIcon.Flag, + ["fire"] = FontAwesomeIcon.Fire, + }; + + private readonly ThemeRegistry _themes; + private readonly TokenResolver _resolver; + private readonly FontManager _fonts; + private readonly ILogger _logger; + private readonly Windows.ChannelPopoutPool _pool; + + public Sidebar( + ThemeRegistry themes, + TokenResolver resolver, + FontManager fonts, + ILogger logger, + Windows.ChannelPopoutPool pool + ) + { + _themes = themes; + _resolver = resolver; + _fonts = fonts; + _logger = logger; + _pool = pool; + } + + public bool IsExpanded(float windowWidth) => + windowWidth >= Plugin.Config.SidebarAutoSwitchThresholdPx; + + public float GetWidth(float windowWidth) => + IsExpanded(windowWidth) + ? Math.Clamp((float)Plugin.Config.SidebarWidth, MinSidebarWidth, MaxSidebarWidth) + : IconOnlyWidth; + + // Factored click logic so the SelfTest exercises the real toggle, not a direct + // MarkGreeted call (which would be a dead path the render never takes). + internal void ToggleGreetedForSelfTest(Tab tab) + { + if (Plugin.Instance.AutoTellTabsService.IsGreeted(tab)) + Plugin.Instance.AutoTellTabsService.UnmarkGreeted(tab); + else + Plugin.Instance.AutoTellTabsService.MarkGreeted(tab); + } + + public void Draw(float windowWidth, IList tabs, ref Tab? activeTab) + { + LastRenderedGreetedGlyphCount = 0; + LastRenderedUnreadDotCount = 0; + LastDrawnSectionHeaderCount = 0; + + if (!_fonts.FontsReady) + { + ImGui.Dummy(new Vector2(IconOnlyWidth, 0)); + return; + } + + var expanded = IsExpanded(windowWidth); + var width = GetWidth(windowWidth); + using var child = ImRaii.Child("##hellion-sidebar", new Vector2(width, 0)); + if (!child.Success) + return; + + var theme = _themes.Active; + var accentRgba = _resolver.Resolve(Token.AccentPrimary, theme.Colors); + var textAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); + var mutedAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted); + var dimAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextDim); + var dangerAbgr = ColourUtil.RgbaToAbgr(theme.Colors.StatusDanger); + var dl = ImGui.GetWindowDrawList(); + + // B3-4 sectioned render order (1.5.6 parity): persistent → pinned + // TempTabs → unpinned TempTabs. Only the display sequence regroups; + // the tab list itself stays untouched and every row keeps its + // ORIGINAL list index for PushID, so an open context-menu popup + // stays bound to its tab when sectioning moves it visually. + var renderOrder = BuildRenderOrder(tabs); + var pinnedHeaderRendered = false; + var unpinnedHeaderRendered = false; + foreach (var i in renderOrder) + { + var tab = tabs[i]; + if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered) + { + DrawSectionHeader( + HellionStrings.PinTab_SectionHeader, + Plugin.Instance.AutoTellTabsService.PinnedTempTabCount + ); + pinnedHeaderRendered = true; + } + else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !unpinnedHeaderRendered) + { + DrawSectionHeader( + HellionStrings.AutoTellTabs_SectionHeader, + Plugin.Instance.AutoTellTabsService.ActiveTempTabCount + ); + unpinnedHeaderRendered = true; + } + + DrawRow( + tab, + i, + expanded, + accentRgba, + textAbgr, + mutedAbgr, + dimAbgr, + dangerAbgr, + dl, + ref activeTab + ); + } + } + + // Section transition marker (1.5.6 parity): the separator always renders, + // compact mode suppresses only the header text. Real cursor-advancing + // widgets on purpose — rows advance the cursor via InvisibleButton, so a + // drawlist-only header would overlap the next row. + private void DrawSectionHeader(string header, int count) + { + ImGui.Separator(); + if (Plugin.Config.AutoTellTabsCompactDisplay) + return; + + ImGui.TextDisabled($"{header} ({count})"); + LastDrawnSectionHeaderCount++; + } + + // Mirror of 1.5.6's BuildSidebarRenderOrder: returns indices into the + // live tab list grouped by section, so the list order itself is never + // mutated and headers gate on the first tab actually reached per pool + // (an empty pool draws neither separator nor header). + private static List BuildRenderOrder(IList tabs) + { + var persistent = new List(tabs.Count); + var pinned = new List(); + var unpinned = new List(); + for (var i = 0; i < tabs.Count; i++) + { + if (TabLifecycleHelpers.IsInPinnedPool(tabs[i])) + pinned.Add(i); + else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i])) + unpinned.Add(i); + else + persistent.Add(i); + } + + persistent.AddRange(pinned); + persistent.AddRange(unpinned); + return persistent; + } + + private void DrawRow( + Tab tab, + int index, + bool expanded, + uint accentRgba, + uint textAbgr, + uint mutedAbgr, + uint dimAbgr, + uint dangerAbgr, + ImDrawListPtr dl, + ref Tab? activeTab + ) + { + ImGui.PushID(index); + + var origin = ImGui.GetCursorScreenPos(); + var avail = ImGui.GetContentRegionAvail().X; + + // Drop the row entirely when the sidebar is dragged below the width + // of a single hit target. ImGui's InvisibleButton asserts on a + // zero-width size, which crashes the whole window at min-drag. + if (avail < 2f) + { + ImGui.PopID(); + return; + } + + // 1.5.6 parity: greeted state dims the tab icon whenever the toggle is + // configured on. The clickable affordance additionally needs an expanded + // sidebar with room for a third hit area beside the pop-out slot — in + // icon-only or min-drag mode it is skipped entirely. + var greetedConfigured = tab.IsTempTab && Plugin.Config.AutoTellTabsShowGreetedToggle; + var showGreeted = + greetedConfigured && expanded && avail > GreetedHitWidth + PopOutHitWidth + 4f; + + // Only split off a separate pop-out hit area when there's room for + // both buttons. Below that, the whole row stays as a single + // selectable strip without the pop-out affordance. + var hasPopOut = avail > PopOutHitWidth + 4f; + var tabHitWidth = hasPopOut ? avail - PopOutHitWidth : avail; + if (showGreeted) + { + // Greeted slot sits at the left edge (1.5.6 placement); the row + // button starts after it so the three hit areas never overlap. + tabHitWidth -= GreetedHitWidth; + ImGui.SetCursorScreenPos(origin + new Vector2(GreetedHitWidth, 0f)); + } + + ImGui.InvisibleButton("row", new Vector2(tabHitWidth, RowHeight)); + var rowHovered = ImGui.IsItemHovered(); + if (ImGui.IsItemClicked()) + { + var previous = activeTab; + activeTab = tab; + TabLifecycleHelpers.OnTabActivated(tab, previous); + } + + dl.DrawHoverSheen( + origin, + origin + new Vector2(avail, RowHeight), + accentRgba, + $"sidebar.tab.{tab.Identifier}", + rowHovered + ); + + var icon = ResolveTabIcon(tab); + + // Dim precedence (1.5.6): the active tab always keeps its regular + // color; only greeted, non-active tabs drop to TextDim. + var isCurrentTab = tab == activeTab; + var iconColor = textAbgr; + if ( + !isCurrentTab + && greetedConfigured + && Plugin.Instance.AutoTellTabsService.IsGreeted(tab) + ) + iconColor = dimAbgr; + + // Icon and label shift right by the greeted slot when it is shown. + var contentX = showGreeted ? GreetedHitWidth : 0f; + using (_fonts.FontAwesome.Push()) + { + var iconStr = icon.ToIconString(); + dl.AddText(origin + new Vector2(10f + contentX, 8f), iconColor, iconStr); + + // 1.5.6-parity unread dot, top-right of the icon. The active tab is + // zeroed every frame (MainWindow.Draw), so the dot never shows on the + // tab you're viewing; UnreadMode.None opts a tab out entirely. + if (!isCurrentTab && tab.UnreadMode != UnreadMode.None && tab.Unread > 0) + { + var iconRight = 10f + contentX + ImGui.CalcTextSize(iconStr).X; + dl.AddCircleFilled(origin + new Vector2(iconRight - 2f, 6f), 4f, dangerAbgr, 12); + LastRenderedUnreadDotCount++; + } + } + + if (expanded) + dl.AddText(origin + new Vector2(32f + contentX, 8f), textAbgr, tab.Name); + + TabContextMenu.Draw(tab, "ctx", _pool); + + var popHovered = false; + if (hasPopOut) + { + ImGui.SameLine(0f, 0f); + ImGui.InvisibleButton("popout", new Vector2(PopOutHitWidth, RowHeight)); + popHovered = ImGui.IsItemHovered(); + if (ImGui.IsItemClicked()) + _pool.TryOpen(tab); + } + + if (hasPopOut && (rowHovered || popHovered)) + { + using (_fonts.FontAwesome.Push()) + { + var glyph = FontAwesomeIcon.ArrowUpRightFromSquare.ToIconString(); + dl.AddText(origin + new Vector2(avail - PopOutHitWidth + 4f, 8f), mutedAbgr, glyph); + } + } + + if (showGreeted) + { + // The hit area sits at the LEFT edge of the row, but the item must + // be submitted AFTER TabContextMenu.Draw — any interactive item + // between the row button and the popup call would steal the + // right-click trigger (B3-1 ordering constraint). + ImGui.SetCursorScreenPos(origin); + ImGui.InvisibleButton("greeted", new Vector2(GreetedHitWidth, RowHeight)); + if (ImGui.IsItemClicked()) + ToggleGreetedForSelfTest(tab); + + // CheckCircle = greeted, plain Check = still pending (1.5.6 mapping). + var greetedGlyph = Plugin.Instance.AutoTellTabsService.IsGreeted(tab) + ? FontAwesomeIcon.CheckCircle + : FontAwesomeIcon.Check; + using (_fonts.FontAwesome.Push()) + dl.AddText(origin + new Vector2(4f, 8f), mutedAbgr, greetedGlyph.ToIconString()); + LastRenderedGreetedGlyphCount++; + } + + ImGui.PopID(); + } + + private static FontAwesomeIcon ResolveTabIcon(Tab tab) + { + if ( + !string.IsNullOrWhiteSpace(tab.Icon) && IconByName.TryGetValue(tab.Icon, out var mapped) + ) + return mapped; + + // Auto-tell tabs always show the envelope, regardless of what their + // SelectedChannels filter is set to. + if (tab.IsTempTab) + return FontAwesomeIcon.Envelope; + + // Channel-type fallback. Walk every selected key, not just the first, + // so a System tab that filters multiple system-flavoured ChatTypes + // still picks up fa-cog when one of the later keys carries the match. + // The Comment default only wins when every key falls into the + // generic-text bucket (Say / Yell / Shout etc.). + foreach (var chatType in tab.SelectedChannels.Keys) + { + var glyph = ResolveByChannelType(chatType); + if (glyph != FontAwesomeIcon.Comment) + return glyph; + } + + // Last-resort name match for tabs that filter exotic ChatTypes the + // mapping above doesn't cover — keeps the System tab visually + // distinct even with a custom channel set. + if (tab.Name.Contains("system", StringComparison.OrdinalIgnoreCase)) + return FontAwesomeIcon.Cog; + + return FontAwesomeIcon.Comment; + } + + private static FontAwesomeIcon ResolveByChannelType(ChatType type) => + type switch + { + ChatType.TellIncoming or ChatType.TellOutgoing => FontAwesomeIcon.Envelope, + ChatType.FreeCompany + or ChatType.FreeCompanyAnnouncement + or ChatType.FreeCompanyLoginLogout => FontAwesomeIcon.Users, + ChatType.Linkshell1 + or ChatType.Linkshell2 + or ChatType.Linkshell3 + or ChatType.Linkshell4 + or ChatType.Linkshell5 + or ChatType.Linkshell6 + or ChatType.Linkshell7 + or ChatType.Linkshell8 + or ChatType.CrossLinkshell1 + or ChatType.CrossLinkshell2 + or ChatType.CrossLinkshell3 + or ChatType.CrossLinkshell4 + or ChatType.CrossLinkshell5 + or ChatType.CrossLinkshell6 + or ChatType.CrossLinkshell7 + or ChatType.CrossLinkshell8 => FontAwesomeIcon.Link, + ChatType.Party or ChatType.CrossParty => FontAwesomeIcon.UserFriends, + ChatType.Alliance => FontAwesomeIcon.Users, + ChatType.NoviceNetwork or ChatType.NoviceNetworkSystem => FontAwesomeIcon.Users, + ChatType.PvpTeam or ChatType.PvpTeamAnnouncement or ChatType.PvpTeamLoginLogout => + FontAwesomeIcon.Users, + ChatType.System + or ChatType.BattleSystem + or ChatType.GatheringSystem + or ChatType.Error + or ChatType.Notice + or ChatType.LootNotice + or ChatType.Echo => FontAwesomeIcon.Cog, + ChatType.CustomEmote or ChatType.StandardEmote => FontAwesomeIcon.Comments, + _ => FontAwesomeIcon.Comment, + }; +} diff --git a/HellionChat/Ui/StatusBar.cs b/HellionChat/Ui/Components/StatusBar.cs similarity index 74% rename from HellionChat/Ui/StatusBar.cs rename to HellionChat/Ui/Components/StatusBar.cs index 170fea3..9342c5b 100644 --- a/HellionChat/Ui/StatusBar.cs +++ b/HellionChat/Ui/Components/StatusBar.cs @@ -6,35 +6,43 @@ using Dalamud.Interface.Utility; using Dalamud.Interface.Utility.Raii; using HellionChat.Code; using HellionChat.Resources; +using HellionChat.Themes; using HellionChat.Util; -namespace HellionChat.Ui; +namespace HellionChat.Ui.Components; // Bottom status bar. Slots left to right: channel indicator, privacy badge, -// counts, tells (hidden at 0), version (right-aligned). Updates at 1Hz; -// format strings are cached between updates. +// counts, tells (hidden at 0), version (right-aligned). Updates at 1Hz to +// keep the per-frame cost down on slow systems; format strings cache +// between updates and only recompute on the tick boundary. internal sealed class StatusBar { - // DPI-aware bar height. The previous fixed 22px constant clipped on - // Windows display-scaling >100% because ImGui renders the font bigger - // than the reservation. GetTextLineHeightWithSpacing scales with the - // current ImGui font; the 2px spacer is GlobalScale-rounded to stay - // on integer pixel boundaries (same idiom as v1.4.6 F7.2 underline-pill - // in ChatLogWindow.cs:1639-1653). + // DPI-aware bar height. A fixed pixel constant clipped at display + // scaling above 100% — GetTextLineHeightWithSpacing scales with the + // active ImGui font, the 2px spacer rounds against GlobalScale so the + // result lands on integer pixel boundaries. public static float Height => ImGui.GetTextLineHeightWithSpacing() + MathF.Round(2f * ImGuiHelpers.GlobalScale); private const long UpdateIntervalMs = 1000; - // Initially outdated so the first frame always computes fresh. + private readonly ThemeRegistry _themes; + private readonly FontManager _fonts; + private long _lastUpdateMs = -UpdateIntervalMs; private string _cachedCountsText = string.Empty; private string _cachedTellsText = string.Empty; - // Pure string logic, testable without ImGui init. + public StatusBar(ThemeRegistry themes, FontManager fonts) + { + _themes = themes; + _fonts = fonts; + } + + // Pure string logic so the build suite can pin format edge cases + // (locale-sensitive k-suffix, singular/plural pivot) without ImGui. public static string FormatCounts(int tabs, int messages) { - // InvariantCulture so locale doesn't affect the format (e.g. de_DE "1,2k"). var msgPart = messages >= 1000 ? string.Format(CultureInfo.InvariantCulture, "{0:0.0}k msg", messages / 1000.0) @@ -43,7 +51,6 @@ internal sealed class StatusBar return $"{tabsPart} · {msgPart}"; } - // Pure string logic, testable without ImGui init. Returns empty string at 0 tells. public static string FormatTells(int count) { if (count <= 0) @@ -51,7 +58,8 @@ internal sealed class StatusBar return $"{count} {(count == 1 ? "tell" : "tells")}"; } - // Single-pass replacement for a LINQ Sum+Count pair. Pure helper for unit testing. + // Single-pass aggregator — same shape as the previous helper so the + // build-suite test continues to pin the contract. internal static (int messages, int tells) AggregateForStatusBar(IList tabs) { int messages = 0, @@ -65,7 +73,6 @@ internal sealed class StatusBar return (messages, tells); } - // Test hook to verify cache logic without a real time source. internal (string counts, string tells) SnapshotForTest( long now, int tabs, @@ -86,18 +93,24 @@ internal sealed class StatusBar _lastUpdateMs = now; } - public void Draw(Plugin plugin) + public void Draw(Tab? activeTab) { - var theme = plugin.ThemeRegistry.Active; - var now = Environment.TickCount64; + if (!_fonts.FontsReady) + { + ImGui.Dummy(new Vector2(0, Height)); + return; + } + var theme = _themes.Active; + var now = Environment.TickCount64; if (now - _lastUpdateMs >= UpdateIntervalMs) { var (messages, tells) = AggregateForStatusBar(Plugin.Config.Tabs); UpdateCacheIfDue(now, Plugin.Config.Tabs.Count, messages, tells); } - // Border top via DrawList -- ImGui.Separator has too much padding. + // Top border via DrawList — ImGui.Separator has too much padding for + // a tight bottom strip. var cursorY = ImGui.GetCursorScreenPos().Y; var winLeft = ImGui.GetWindowPos().X; var winRight = winLeft + ImGui.GetWindowSize().X; @@ -109,18 +122,15 @@ internal sealed class StatusBar ColourUtil.RgbaToAbgr(theme.Colors.Border), 1f ); - ImGui.Dummy(new Vector2(0, 2)); // Slot 1: active channel indicator - var inputCh = plugin.CurrentTab?.CurrentChannel?.Channel ?? InputChannel.Invalid; + var inputCh = activeTab?.CurrentChannel?.Channel ?? InputChannel.Invalid; var hasChannel = inputCh != InputChannel.Invalid; var chatType = inputCh.ToChatType(); var channelName = hasChannel ? chatType.Name() : "—"; - var channelColor = hasChannel - ? (plugin.Functions.Chat.GetChannelColor(chatType) ?? theme.Colors.TextMuted) - : theme.Colors.TextMuted; - DrawDot(channelColor); + var dotColor = hasChannel ? theme.Colors.Primary : theme.Colors.TextMuted; + DrawDot(dotColor); ImGui.SameLine(); ImGui.TextUnformatted(channelName); @@ -128,10 +138,8 @@ internal sealed class StatusBar ImGui.SameLine(); DrawSeparator(); ImGui.SameLine(); - using (plugin.FontManager.FontAwesome.Push()) - { + using (_fonts.FontAwesome.Push()) ImGui.TextUnformatted(FontAwesomeIcon.Lock.ToIconString()); - } ImGui.SameLine(); var privacyLabel = Plugin.Config.PrivacyFilterEnabled ? HellionStrings.StatusBar_Privacy_Enabled @@ -153,9 +161,8 @@ internal sealed class StatusBar ImGui.TextUnformatted(_cachedTellsText); } - // Slot 5: version, right-aligned, muted. Hidden when the window is - // too narrow to fit all five slots — the other four need ~200 px - // before the version text starts clipping into them. + // Slot 5: version + brand, right-aligned, muted. Hidden when the + // window cannot fit all five slots without overlap. var versionText = $"v{Plugin.Interface.Manifest.AssemblyVersion} · Hellion"; var versionWidth = ImGui.CalcTextSize(versionText).X; var contentRegionMax = ImGui.GetContentRegionMax().X; @@ -164,9 +171,7 @@ internal sealed class StatusBar { ImGui.SameLine(contentRegionMax - versionWidth); using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted))) - { ImGui.TextUnformatted(versionText); - } } } @@ -184,8 +189,5 @@ internal sealed class StatusBar ImGui.Dummy(new Vector2(radius * 2 + 4, ImGui.GetTextLineHeight())); } - private static void DrawSeparator() - { - ImGui.TextDisabled("·"); - } + private static void DrawSeparator() => ImGui.TextDisabled("·"); } diff --git a/HellionChat/Ui/SymbolPicker.cs b/HellionChat/Ui/Components/SymbolPicker.cs similarity index 86% rename from HellionChat/Ui/SymbolPicker.cs rename to HellionChat/Ui/Components/SymbolPicker.cs index bfc426f..04a6998 100644 --- a/HellionChat/Ui/SymbolPicker.cs +++ b/HellionChat/Ui/Components/SymbolPicker.cs @@ -3,14 +3,14 @@ using Dalamud.Bindings.ImGui; using Dalamud.Game.Text; using Dalamud.Interface.Utility.Raii; -namespace HellionChat.Ui; +namespace HellionChat.Ui.Components; // Popup picker for chat-input symbol insertion. Two tabs: -// PUA — Dalamud's SeIconChar enum (161 server-safe FFXIV glyphs) -// BMP — server-verified Unicode symbols (whitelist built 2026-05-16) +// PUA — Dalamud's SeIconChar enum (server-safe FFXIV glyphs) +// BMP — server-verified Unicode symbols (whitelist probed via /echo + /say) // -// Render-only — the Settings-Guard for showing the trigger button lives on -// the caller side (ChatLogWindow). Recent-Used is session state by design. +// Render-only — the visibility toggle for the trigger button lives on the +// caller side (InputBar). Recent-Used is session state by design. internal sealed class SymbolPicker { private const string PopupId = "HellionSymbolPicker"; @@ -19,10 +19,9 @@ internal sealed class SymbolPicker private string _search = string.Empty; private readonly List _recentUsed = new(capacity: RecentCapacity); - // FFXIV server-safe BMP symbols, verified 2026-05-16 via /echo + /say. - // Filtered ranges: U+2694-26C4 (Misc Symbols Extended), U+2700+ (Dingbats - // Extended), diagonal arrows, U+2153+ fractions, chess pieces. - // Full probe log: Cycles/v1.4.10 BMP-Whitelist Notes.md. + // FFXIV server-safe BMP symbols, verified via /echo + /say. Filtered + // ranges live in the v1.4.10 BMP-Whitelist Notes for the original probe; + // the list stays inline so the picker has no external lookup table. private static readonly (uint Codepoint, string Name)[] BmpWhitelist = new[] { (0x00A1u, "Inverted Exclamation"), @@ -131,16 +130,12 @@ internal sealed class SymbolPicker // chat-input buffer at the current cursor position. public string? DrawAndConsume() { - // ImRaii.Popup auto-disposes EndPopup, same idiom as other popups in - // ChatLogWindow. using var popup = ImRaii.Popup(PopupId); if (!popup) return null; string? inserted = null; - // Recent-Used-Row sits above the tabs so both PUA and BMP picks share - // one fast-access strip. Session-only by design (see TrackRecent). if (_recentUsed.Count > 0) { ImGui.TextDisabled("Recent"); @@ -205,12 +200,8 @@ internal sealed class SymbolPicker query.Length > 0 && label.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0 ) - { continue; - } - // ToIconString gives the single-codepoint glyph; tooltip - // carries the enum name for discoverability. if ( ImGui.Selectable( icon.ToIconString(), @@ -225,9 +216,8 @@ internal sealed class SymbolPicker if (ImGui.IsItemHovered()) ImGui.SetTooltip(label); - // Manually-wrapping pattern from imgui_demo.cpp; - // GetWindowContentRegionMax obsolete since ImGui 1.92, use - // GetContentRegionAvail (see ChatLogWindow.cs:840). + // Manual wrap — GetWindowContentRegionMax was deprecated in + // ImGui 1.92, so we compute the right edge ourselves. var style = ImGui.GetStyle(); var lastItemX2 = ImGui.GetItemRectMax().X; var availableRightX = @@ -257,9 +247,7 @@ internal sealed class SymbolPicker foreach (var (codepoint, name) in BmpWhitelist) { if (query.Length > 0 && name.IndexOf(query, StringComparison.OrdinalIgnoreCase) < 0) - { continue; - } var glyph = char.ConvertFromUtf32((int)codepoint); if ( @@ -276,8 +264,6 @@ internal sealed class SymbolPicker if (ImGui.IsItemHovered()) ImGui.SetTooltip(name); - // Same manually-wrapping pattern as DrawPuaTab — modern API - // since GetWindowContentRegionMax was deprecated in ImGui 1.92. var style = ImGui.GetStyle(); var lastItemX2 = ImGui.GetItemRectMax().X; var availableRightX = diff --git a/HellionChat/Ui/Components/TabContextMenu.cs b/HellionChat/Ui/Components/TabContextMenu.cs new file mode 100644 index 0000000..3acb9e8 --- /dev/null +++ b/HellionChat/Ui/Components/TabContextMenu.cs @@ -0,0 +1,144 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility; +using FFXIVClientStructs.FFXIV.Client.UI; +using HellionChat.Resources; +using HellionChat.Util; + +namespace HellionChat.Ui.Components; + +// Shared right-click menu for both tab layouts (Sidebar rows + TopTabBar). One +// source of truth instead of two divergent inline blocks. Static: it has no own +// state and reaches the live Config/Plugin through Plugin.Instance/Plugin.Config. +internal static class TabContextMenu +{ + // MUST be called immediately after the row-carrying ImGui item (Sidebar + // "row" InvisibleButton / TopTabBar Selectable). popupId only names the + // popup; the open trigger is a right-click on the LAST submitted item + // (g.LastItemData via IsItemHovered) — any interactive item in between + // would steal the trigger. Only DrawList ops may sit between. + public static void Draw(Tab tab, string popupId, Windows.ChannelPopoutPool pool) + { + if (!ImGui.BeginPopupContextItem(popupId)) + return; + + // Rename: focus the field the first frame the popup appears. + if (ImGui.IsWindowAppearing()) + ImGui.SetKeyboardFocusHere(); + ImGui.SetNextItemWidth(250f * ImGuiHelpers.GlobalScale); + var name = tab.Name; + if (ImGui.InputText("##tab-name", ref name, 512) && ApplyTabRename(tab, name)) + Plugin.Instance.SaveConfig(); + + // Per-tab notification sound (B3-3). The checkbox gates the picker so + // tabs that never want a sound keep the popup short. + if ( + ImGui.Checkbox( + HellionStrings.Tabs_NotificationSound_Enable_Name, + ref tab.EnableNotificationSound + ) + ) + Plugin.Instance.SaveConfig(); + ImGuiUtil.HelpMarker(HellionStrings.Tabs_NotificationSound_Description); + if (tab.EnableNotificationSound) + DrawSoundPicker(tab); + + if (ImGui.MenuItem("Pop Out")) + pool.TryOpen(tab); + + ImGui.EndPopup(); + } + + // Sound picker: 16 numbered game sounds, a separator, then the 3 bundled + // Hellion clips stored as ids 17-19 (1.5.6 parity order). The collapsed + // preview reuses the entry label scheme so the current pick reads the same + // open or closed. + private static void DrawSoundPicker(Tab tab) + { + var preview = + tab.NotificationSoundId <= 16 + ? $"{HellionStrings.Tabs_NotificationSound_Option} {tab.NotificationSoundId}" + : $"{HellionStrings.Tabs_NotificationSound_CustomOption} {tab.NotificationSoundId - 16}"; + using ( + var combo = ImGuiUtil.BeginComboVertical( + HellionStrings.Tabs_NotificationSound_Option, + preview + ) + ) + { + if (combo.Success) + { + for (uint s = 1; s <= 16; s++) + { + if ( + ImGui.Selectable( + $"{HellionStrings.Tabs_NotificationSound_Option} {s}", + tab.NotificationSoundId == s + ) + ) + { + tab.NotificationSoundId = s; + Plugin.Instance.SaveConfig(); + } + } + + ImGui.Separator(); + + for (uint n = 1; n <= 3; n++) + { + var customId = 16 + n; + if ( + ImGui.Selectable( + $"{HellionStrings.Tabs_NotificationSound_CustomOption} {n}", + tab.NotificationSoundId == customId + ) + ) + { + tab.NotificationSoundId = customId; + Plugin.Instance.SaveConfig(); + } + } + } + } + + if ( + ImGuiUtil.IconButton( + FontAwesomeIcon.Play, + "tab-sound-preview", + HellionStrings.Tabs_NotificationSound_Preview + ) + ) + PreviewSound(tab.NotificationSoundId); + } + + // Preview: 1-16 are game UI sounds (must hit the framework thread); 17+ are + // custom NAudio clips (own playback thread). Open range >= 17 (not 17-19); the + // 3-clip ceiling is guarded inside CustomAudioPlayer. + private static void PreviewSound(uint id) + { + if (id is >= 1 and <= 16) + { + Plugin.Framework.RunOnFrameworkThread(() => + { + unsafe + { + UIGlobals.PlaySoundEffect(id); + } + }); + } + else if (id >= 17) + { + Plugin.Instance.CustomAudioPlayer.Play((int)id - 16, Plugin.Config.CustomSoundVolume); + } + } + + // Factored out so the SelfTest drives the real rename path, not a field poke. + // Returns true when the name actually changed (gates the SaveConfig write). + internal static bool ApplyTabRename(Tab tab, string newName) + { + if (string.IsNullOrEmpty(newName) || newName == tab.Name) + return false; + tab.Name = newName; + return true; + } +} diff --git a/HellionChat/Ui/Components/ThemeQuickPicker.cs b/HellionChat/Ui/Components/ThemeQuickPicker.cs new file mode 100644 index 0000000..3cb763d --- /dev/null +++ b/HellionChat/Ui/Components/ThemeQuickPicker.cs @@ -0,0 +1,143 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Resources; +using HellionChat.Themes; +using HellionChat.Ui.Components.Settings; + +namespace HellionChat.Ui.Components; + +// Restores the 1.5.4 header quick-picker (a46d89c:ChatLogWindow.cs:481-558): a +// palette button in the input-bar button row opening a popup that switches the +// theme (built-in + custom) and jumps between chat tabs without opening settings. +// Switch path mirrors the settings ThemePicker exactly; the tab jump routes +// through MainWindow.ActivateTab so tell/unread handling matches a real tab click. +internal sealed class ThemeQuickPicker +{ + private const string PopupId = "##hellion-quick-picker"; + private const float SectionWidth = 220f; + private const float RowHeight = 22f; + private const float MaxSectionHeight = 200f; + + private readonly ThemeRegistry _themes; + private readonly Plugin _plugin; + + public ThemeQuickPicker(ThemeRegistry themes, Plugin plugin) + { + _themes = themes; + _plugin = plugin; + } + + public void OpenPopup() => ImGui.OpenPopup(PopupId); + + public void Draw() + { + using var popup = ImRaii.Popup(PopupId); + if (!popup) + return; + + DrawThemeSection(); + ImGui.Spacing(); + DrawTabSection(); + } + + private void DrawThemeSection() + { + ImGui.TextDisabled(HellionStrings.Settings_QuickPicker_Themes_Header); + ImGui.Separator(); + + var themes = AllThemes(); + var height = MathF.Min(themes.Count * RowHeight, MaxSectionHeight); + using var child = ImRaii.Child( + "##hellion-quick-picker-themes", + new Vector2(SectionWidth, height) + ); + if (!child) + return; + + var activeSlug = _themes.Active.Slug; + foreach (var theme in themes) + { + var isActive = string.Equals( + theme.Slug, + activeSlug, + StringComparison.OrdinalIgnoreCase + ); + DrawGlyph(isActive); + if ( + ImGui.Selectable( + $"{theme.Name}##quick-theme-{theme.Slug}", + isActive, + ImGuiSelectableFlags.DontClosePopups + ) && !isActive + ) + { + _themes.Switch(theme.Slug); + Plugin.Config.Theme = theme.Slug; + _plugin.SaveConfig(); + } + } + } + + private void DrawTabSection() + { + ImGui.TextDisabled(HellionStrings.Settings_QuickPicker_Tabs_Header); + ImGui.Separator(); + + // Snapshot so a worker-thread temp-tab strip can't shift the list mid-loop. + var tabs = Plugin.Config.Tabs.ToList(); + var height = MathF.Min(tabs.Count * RowHeight, MaxSectionHeight); + using var child = ImRaii.Child( + "##hellion-quick-picker-tabs", + new Vector2(SectionWidth, height) + ); + if (!child) + return; + + var window = _plugin.MainWindow; + var active = window?.ActiveTab; + for (var i = 0; i < tabs.Count; i++) + { + var tab = tabs[i]; + var isActive = ReferenceEquals(tab, active); + DrawGlyph(isActive); + if ( + ImGui.Selectable( + $"{tab.Name}##quick-tab-{i}", + isActive, + ImGuiSelectableFlags.DontClosePopups + ) && !isActive + ) + { + window?.ActivateTab(tab); + } + } + } + + // Leading check glyph for the active row; inactive rows reserve an equal-width + // blank so labels stay aligned. The FontAwesome font is pushed on its own line + // then SameLine() so it doesn't bleed into the body-font label (1.5.4 trick). + private void DrawGlyph(bool isActive) + { + var check = FontAwesomeIcon.Check.ToIconString(); + using (_plugin.FontManager.FontAwesome.Push()) + { + if (isActive) + ImGui.TextUnformatted(check); + else + ImGui.Dummy(new Vector2(ImGui.CalcTextSize(check).X, ImGui.GetTextLineHeight())); + } + ImGui.SameLine(); + } + + private List AllThemes() + { + var all = new List(); + foreach (var slug in ThemePicker.CategoryMapSlugs) + if (_themes.TryGet(slug, out var theme)) + all.Add(theme); + all.AddRange(_themes.AllCustom()); + return all; + } +} diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs new file mode 100644 index 0000000..5010335 --- /dev/null +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -0,0 +1,72 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using HellionChat.Util; + +namespace HellionChat.Ui.Components; + +// Horizontal tab strip — the alternative MainWindow layout to the Sidebar. +// Selection drives the same shared EnsureCurrentChannel path; pop-out is the +// same pool.TryOpen affordance as the sidebar (right-click context menu). +internal sealed class TopTabBar +{ + private readonly Windows.ChannelPopoutPool _pool; + + public TopTabBar(Windows.ChannelPopoutPool pool) + { + _pool = pool; + } + + public void Draw(IList tabs, ref Tab? activeTab) + { + for (var i = 0; i < tabs.Count; i++) + { + var tab = tabs[i]; + if (i > 0) + ImGui.SameLine(); + + var selected = ReferenceEquals(tab, activeTab); + // Size the selectable to its own label width. A zero width makes ImGui + // stretch the selectable's box to the full remaining window width + // (imgui_widgets.cpp:7378), so in this SameLine row every tab overlaps + // into one giant bar and clicking never lands on the intended tab. + var tabWidth = ImGui.CalcTextSize(tab.Name).X; + if ( + ImGui.Selectable( + $"{tab.Name}###hellion_toptab_{i}", + selected, + ImGuiSelectableFlags.None, + new Vector2(tabWidth, 0) + ) + ) + { + var previous = activeTab; + activeTab = tab; + TabLifecycleHelpers.OnTabActivated(tab, previous); + } + + // 1.5.6-parity unread dot at the item's top-right. Gate on the + // POST-click selection (not the frame-start 'selected') so clicking a + // tab suppresses its dot the same frame, like the sidebar. The active + // tab is also zeroed every frame (MainWindow.Draw). + if ( + !ReferenceEquals(tab, activeTab) + && tab.UnreadMode != UnreadMode.None + && tab.Unread > 0 + ) + { + var max = ImGui.GetItemRectMax(); + var min = ImGui.GetItemRectMin(); + var danger = ColourUtil.RgbaToAbgr( + Plugin.Instance.ThemeRegistry.Active.Colors.StatusDanger + ); + ImGui + .GetWindowDrawList() + .AddCircleFilled(new Vector2(max.X - 4f, min.Y + 4f), 3.5f, danger, 12); + } + + TabContextMenu.Draw(tab, $"toptab_ctx_{i}", _pool); + } + + ImGui.Separator(); + } +} diff --git a/HellionChat/Ui/DbViewer.cs b/HellionChat/Ui/DbViewer.cs index 93afbad..b539f72 100644 --- a/HellionChat/Ui/DbViewer.cs +++ b/HellionChat/Ui/DbViewer.cs @@ -391,10 +391,10 @@ public class DbViewer : Window ImGuiUtil.Tooltip(message.Code.Type.Name()); ImGui.TableNextColumn(); - Plugin.ChatLogWindow.DrawChunks(message.Sender); + ImGui.TextUnformatted(string.Join("", message.Sender.Select(c => c.StringValue()))); ImGui.TableNextColumn(); - Plugin.ChatLogWindow.DrawChunks(message.Content); + ImGui.TextWrapped(string.Join("", message.Content.Select(c => c.StringValue()))); } } diff --git a/HellionChat/Ui/Debugger.cs b/HellionChat/Ui/Debugger.cs index acb1921..f2c3bc7 100644 --- a/HellionChat/Ui/Debugger.cs +++ b/HellionChat/Ui/Debugger.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; using Dalamud.Bindings.ImGui; using Dalamud.Interface.Colors; using Dalamud.Interface.Utility; @@ -9,16 +9,19 @@ using Lumina.Text.ReadOnly; namespace HellionChat.Ui; -public class DebuggerWindow : Window, IDisposable +// Dev tool. Reduced to the parts that survive without the legacy chat +// window: PayloadHandler counters, current-tab channel state, and the +// vanilla chat channel label. +internal sealed class DebuggerWindow : Window, IDisposable { private readonly Plugin Plugin; - private readonly ChatLogWindow ChatLogWindow; + private readonly PayloadHandler _payloadHandler; - public DebuggerWindow(Plugin plugin) + internal DebuggerWindow(Plugin plugin, PayloadHandler payloadHandler) : base("Debugger###chat2-debugger") { Plugin = plugin; - ChatLogWindow = plugin.ChatLogWindow; + _payloadHandler = payloadHandler; SizeConstraints = new WindowSizeConstraints { @@ -30,29 +33,21 @@ public class DebuggerWindow : Window, IDisposable DisableWindowSounds = true; } - public void Dispose() - { - // Slash-command tear-down moved to Plugin.TearDownCommands. - } + public void Dispose() { } public override unsafe void Draw() { var agent = (nint)AgentItemDetail.Instance(); - ImGui.TextUnformatted($"Current Cursor Pos: {ChatLogWindow.CursorPos}"); if (ImGui.Selectable($"Agent Address: {agent:X}")) ImGui.SetClipboardText(agent.ToString("X")); ImGuiHelpers.ScaledDummy(5.0f); - - ImGui.TextUnformatted($"Handle Tooltips: {ChatLogWindow.PayloadHandler.HandleTooltips}"); - ImGui.TextUnformatted($"Hovered Item: {ChatLogWindow.PayloadHandler.HoveredItem}"); - ImGui.TextUnformatted($"Hover Counter: {ChatLogWindow.PayloadHandler.HoverCounter}"); - ImGui.TextUnformatted( - $"Last Hover Counter: {ChatLogWindow.PayloadHandler.LastHoverCounter}" - ); + ImGui.TextUnformatted($"Handle Tooltips: {_payloadHandler.HandleTooltips}"); + ImGui.TextUnformatted($"Hovered Item: {_payloadHandler.HoveredItem}"); + ImGui.TextUnformatted($"Hover Counter: {_payloadHandler.HoverCounter}"); + ImGui.TextUnformatted($"Last Hover Counter: {_payloadHandler.LastHoverCounter}"); ImGuiHelpers.ScaledDummy(5.0f); - ImGui.TextColored(ImGuiColors.DalamudOrange, "Current Tab"); ImGui.TextUnformatted($"Name: {Plugin.CurrentTab.Name}"); ImGui.TextUnformatted( @@ -74,7 +69,6 @@ public class DebuggerWindow : Window, IDisposable ); ImGuiHelpers.ScaledDummy(5.0f); - ImGui.TextColored(ImGuiColors.DalamudOrange, "Vanilla Chat"); ImGui.TextUnformatted( $"Channel: {new ReadOnlySeString(AgentChatLog.Instance()->ChannelLabel).ExtractText()}" diff --git a/HellionChat/Ui/HellionStyleHelpers.cs b/HellionChat/Ui/HellionStyleHelpers.cs deleted file mode 100644 index d257681..0000000 --- a/HellionChat/Ui/HellionStyleHelpers.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace HellionChat.Ui; - -internal static class HellionStyleHelpers -{ - // Child surfaces are drawn over WindowBg, so at partial window opacity - // the theme's own ChildBg alpha would double-multiply and read too solid. - // Above ~full opacity we preserve the theme alpha; below it we wipe to 0 - // so WindowBg alone carries the coverage. The 0.999f threshold is a - // float-imprecision guard around the user-facing 100% slider value. - // TEST-MIRROR: ../../Hellion Build test/_Helpers/HellionStyleHelpersTests.cs - public static uint ResolveChildBgAlpha(uint themeChildBgRgba, float windowOpacity) - { - var alphaPreserved = windowOpacity >= 0.999f; - var childBgAlpha = alphaPreserved ? (themeChildBgRgba & 0xFFu) : 0u; - return (themeChildBgRgba & 0xFFFFFF00u) | childBgAlpha; - } -} diff --git a/HellionChat/Ui/InputPreview.cs b/HellionChat/Ui/InputPreview.cs index 3f32a21..28af46e 100644 --- a/HellionChat/Ui/InputPreview.cs +++ b/HellionChat/Ui/InputPreview.cs @@ -1,39 +1,46 @@ using System.Numerics; using System.Text; -using System.Text.RegularExpressions; using Dalamud.Bindings.ImGui; using Dalamud.Game.Text; using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Game.Text.SeStringHandling.Payloads; using Dalamud.Interface.Utility.Raii; using Dalamud.Interface.Windowing; -using Dalamud.Plugin.Services; using HellionChat.Code; using HellionChat.Resources; using HellionChat.Util; +using Microsoft.Extensions.Logging; namespace HellionChat.Ui; -public partial class InputPreview : Window +internal sealed class InputPreview : Window { - private ChatLogWindow LogWindow { get; } + private readonly Components.ChunkRenderer _chunkRenderer; + private readonly Lender _handlerLender; + private readonly Windows.MainWindow _mainWindow; + private readonly Components.InputBar _inputBar; + private readonly ILogger _logger; - private bool Drawing; - private bool HasEvaluation; + private bool _drawing; + private bool _hasEvaluation; internal float PreviewHeight; - private int LastLength; - private Message? PreviewMessage; + private int _lastLength; + private Message? _previewMessage; - private int CursorPosition; - private bool NextChunkIsAutoTranslate; - - internal int SelectedCursorPos = -1; - - internal InputPreview(ChatLogWindow logWindow) + internal InputPreview( + Components.ChunkRenderer chunkRenderer, + Lender handlerLender, + Windows.MainWindow mainWindow, + Components.InputBar inputBar, + ILogger logger + ) : base("##chat2-inputpreview") { - LogWindow = logWindow; + _chunkRenderer = chunkRenderer; + _handlerLender = handlerLender; + _mainWindow = mainWindow; + _inputBar = inputBar; + _logger = logger; Flags = ImGuiWindowFlags.NoSavedSettings @@ -47,52 +54,60 @@ public partial class InputPreview : Window DisableWindowSounds = true; IsOpen = true; - Plugin.Framework.Update += UpdateConditionCheck; + // TODO Polish-Sweep: remove discard once logging call-sites exist + _ = _logger; } - public void Dispose() - { - Plugin.Framework.Update -= UpdateConditionCheck; - } + public void Dispose() { } private bool ValidDraw => - !string.IsNullOrEmpty(LogWindow.Chat) - && LogWindow.Chat.Length >= Plugin.Config.PreviewMinimum; + !string.IsNullOrEmpty(_inputBar.PendingMessage) + && _inputBar.PendingMessage.Length >= Plugin.Config.PreviewMinimum; - private void UpdateConditionCheck(IFramework framework) + // IsDrawable gates DrawConditions; it is also consumed externally by + // any component that needs to know whether the preview popup is visible. + internal bool IsDrawable => ValidDraw && _hasEvaluation; + + private static bool IsWindowMode => + Plugin.Config.PreviewPosition is PreviewPosition.Top or PreviewPosition.Bottom; + + // PreOpenCheck owns state: it runs once per frame before the visibility + // gate so Drawing/PreviewMessage/HasEvaluation stay fresh even when the + // window is not ultimately drawn. PreDraw owns position/size to avoid + // wasted computation on frames where DrawConditions returns false + // (position math only matters when the window is about to render). + // This matches the v1.5.6 UpdateConditionCheck/PreDraw split — the + // Framework.Update subscribe is removed; PreOpenCheck runs at the same + // cadence via Dalamud's WindowSystem. + public override void PreOpenCheck() { - Drawing = ValidDraw; - if (!Drawing) + _drawing = ValidDraw; + if (!_drawing) { - LastLength = 0; + _lastLength = 0; PreviewHeight = 0; - PreviewMessage = null; - HasEvaluation = false; - + _previewMessage = null; + _hasEvaluation = false; return; } - if (PreviewMessage == null || LastLength != LogWindow.Chat.Length) + if (_previewMessage == null || _lastLength != _inputBar.PendingMessage.Length) { - LastLength = LogWindow.Chat.Length; + _lastLength = _inputBar.PendingMessage.Length; - var bytes = Encoding.UTF8.GetBytes(LogWindow.Chat.Trim()); + var bytes = Encoding.UTF8.GetBytes(_inputBar.PendingMessage.Trim()); AutoTranslate.ReplaceWithPayload(ref bytes); var chunks = ChunkUtil .ToChunks(SeString.Parse(bytes), ChunkSource.Content, ChatType.Say) .ToList(); - PreviewMessage = Message.FakeMessage(chunks, new ChatCode(XivChatType.Say, 0, 0)); - PreviewMessage.DecodeTextParam(); + _previewMessage = Message.FakeMessage(chunks, new ChatCode(XivChatType.Say, 0, 0)); + _previewMessage.DecodeTextParam(); } - HasEvaluation = !Plugin.Config.OnlyPreviewIf || PreviewMessage.Content.Count > 1; + + _hasEvaluation = !Plugin.Config.OnlyPreviewIf || _previewMessage.Content.Count > 1; } - internal bool IsDrawable => ValidDraw && HasEvaluation; - - private static bool IsWindowMode => - Plugin.Config.PreviewPosition is PreviewPosition.Top or PreviewPosition.Bottom; - public override bool DrawConditions() { return IsWindowMode && IsDrawable; @@ -100,8 +115,8 @@ public partial class InputPreview : Window public override void PreDraw() { - var pos = LogWindow.LastWindowPos; - var size = LogWindow.LastWindowSize; + var pos = _mainWindow.LastWindowPos; + var size = _mainWindow.LastWindowSize; Size = size with { Y = PreviewHeight }; @@ -122,13 +137,14 @@ public partial class InputPreview : Window public override void Draw() { - CalculatePreview(); + CalculatePreviewHeight(); DrawPreview(); } - internal void CalculatePreview() + internal void CalculatePreviewHeight() { - // We Pre-draw this once to get the actual height :HideThePain: + // Pre-draw offscreen once to measure actual rendered height; value is + // consumed next frame by PreDraw() for window sizing. PreviewHeight = 0; var pos = ImGui.GetCursorPos(); @@ -137,7 +153,7 @@ public partial class InputPreview : Window using (ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero)) { ImGui.TextUnformatted(Language.Options_Preview_Header); - DrawChunksPreview(PreviewMessage!.Content); + _chunkRenderer.DrawChunks(_previewMessage!.Content, wrap: true, lineWidth: 0f); } var after = ImGui.GetCursorPosY(); ImGui.SetCursorPos(pos); @@ -152,147 +168,20 @@ public partial class InputPreview : Window { ImGui.TextUnformatted(Language.Options_Preview_Header); - var handler = LogWindow.HandlerLender.Borrow(); - DrawChunksPreview(PreviewMessage!.Content, handler, unique: 10000); + // Primary path (A2) resets the Lender counter in MainWindow.Draw(); + // this fallback covers the edge-case where MainWindow is closed but + // InputPreview is still open, preventing handler pool growth. + if (!_mainWindow.IsOpen) + _handlerLender.ResetCounter(); + + var handler = _handlerLender.Borrow(); + _chunkRenderer.DrawChunks( + _previewMessage!.Content, + wrap: true, + handler: handler, + lineWidth: 0f + ); handler.Draw(); } } - - private void DrawChunksPreview( - IReadOnlyList chunks, - PayloadHandler? handler = null, - float lineWidth = 0f, - int unique = 0 - ) - { - CursorPosition = 0; - - using var style = ImRaii.PushStyle(ImGuiStyleVar.ItemSpacing, Vector2.Zero); - for (var i = 0; i < chunks.Count; i++) - { - if (chunks[i] is TextChunk text && string.IsNullOrEmpty(text.Content)) - continue; - - DrawChunkPreview(chunks[i], handler, lineWidth, unique); - - if (i < chunks.Count - 1) - { - ImGui.SameLine(); - } - else if (chunks[i].Link is EmotePayload && Plugin.Config.ShowEmotes) - { - // Emote payloads seem to not automatically put newlines, which - // is an issue when modern mode is disabled. - ImGui.SameLine(); - // Use default ImGui behavior for newlines. - ImGui.TextUnformatted(""); - } - } - } - - private void DrawChunkPreview( - Chunk chunk, - PayloadHandler? handler = null, - float lineWidth = 0f, - int unique = 0 - ) - { - if (chunk is IconChunk icon) - { - LogWindow.DrawIcon(chunk, icon, handler); - if (icon.Icon != BitmapFontIcon.AutoTranslateBegin) - return; - - NextChunkIsAutoTranslate = true; - // Malformed chunks could carry an AutoTranslateBegin icon without the matching - // payload; bail out instead of dereferencing a null Link. - if (chunk.Link is not AutoTranslatePayload payload) - return; - CursorPosition += $"".Length; - - return; - } - - if (chunk is not TextChunk text) - return; - - if (chunk.Link is EmotePayload emotePayload && Plugin.Config.ShowEmotes) - { - var emoteSize = ImGui.CalcTextSize("W"); - emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f; - - // TextWrap doesn't work for emotes, so we have to wrap them manually - if (ImGui.GetContentRegionAvail().X < emoteSize.X) - ImGui.NewLine(); - - // We only draw a dummy if it is still loading, in case it failed, we draw the actual name - var image = EmoteCache.GetEmote(emotePayload.Code); - if (image is { Failed: false }) - { - if (image.IsLoaded) - image.Draw(emoteSize); - else - ImGui.Dummy(emoteSize); - - if (ImGui.IsItemHovered()) - ImGuiUtil.Tooltip(emotePayload.Code); - - CursorPosition += emotePayload.Code.Length; - return; - } - } - - if (NextChunkIsAutoTranslate) - { - NextChunkIsAutoTranslate = false; - ImGuiUtil.WrapText(text.Content, chunk, handler, LogWindow.DefaultText, lineWidth); - return; - } - - if (text.Link != null) - { - if (text.Link is ItemPayload) - CursorPosition += "".Length; - else if (text.Link is MapLinkPayload) - CursorPosition += "".Length; - else if (text.Link is EmotePayload emote) - CursorPosition += emote.Code.Length; - else if (text.Link is UriPayload) - CursorPosition += text.Content.Length; - - ImGuiUtil.WrapText(text.Content, chunk, handler, LogWindow.DefaultText, lineWidth); - return; - } - - foreach (var word in WhitespaceRegex().Split(text.Content).Where(s => s != string.Empty)) - { - var wordSize = ImGui.CalcTextSize(word); - if (ImGui.GetContentRegionAvail().X < wordSize.X) - ImGui.NewLine(); - - foreach (var letter in word) - { - var letterSize = ImGui.CalcTextSize(letter.ToString()); - - CursorPosition++; - if ( - ImGui.Selectable( - $"{letter}##{CursorPosition + unique}", - false, - ImGuiSelectableFlags.None, - letterSize - ) - ) - { - SelectedCursorPos = CursorPosition; - LogWindow.FocusedPreview = true; - } - ImGui.SameLine(); - } - } - ImGui.NewLine(); - } - - [GeneratedRegex(@"(\s)")] - private static partial Regex WhitespaceRegex(); } diff --git a/HellionChat/Ui/Popout.cs b/HellionChat/Ui/Popout.cs deleted file mode 100644 index 95c6eb4..0000000 --- a/HellionChat/Ui/Popout.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System.Numerics; -using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Style; -using Dalamud.Interface.Utility.Raii; -using Dalamud.Interface.Windowing; -using Microsoft.Extensions.Logging; - -namespace HellionChat.Ui; - -internal class Popout : Window -{ - private readonly ChatLogWindow ChatLogWindow; - private readonly Tab Tab; - private readonly int Idx; - private readonly ILogger _logger; - - private long FrameTime; - private long LastActivityTime = Environment.TickCount64; - - // Optional input bar inside the pop-out. Lazy-allocated when enabled, - // torn down on toggle-off (buffer discarded intentionally). - public ChatInputBar? InputBar { get; private set; } - public bool HasFocusedInputBar => InputBar?.IsFocused ?? false; - - // Exposed so AutoTellTabsService can locate this window during LRU eviction. - internal Guid TabIdentifier => Tab.Identifier; - - public Popout(ChatLogWindow chatLogWindow, Tab tab, int idx, ILogger logger) - : base($"{tab.Name}##popout") - { - ChatLogWindow = chatLogWindow; - Tab = tab; - Idx = idx; - _logger = logger; - - Size = new Vector2(350, 350); - SizeCondition = ImGuiCond.FirstUseEver; - - IsOpen = true; - RespectCloseHotkey = false; - DisableWindowSounds = true; - // AllowBackgroundBlur is intentionally off: Dalamud blurs the entire - // tab container, not just this window, which would affect adjacent plugins. - // Users can enable blur per-window via the Dalamud hamburger menu. - } - - public override void PreOpenCheck() - { - if (!Tab.PopOut) - IsOpen = false; - } - - public override bool DrawConditions() - { - FrameTime = Environment.TickCount64; - if (Tab.IndependentHide ? HideStateCheck() : ChatLogWindow.IsHidden) - return false; - - if ( - !Plugin.Config.HideWhenInactive - || (!Plugin.Config.InactivityHideActiveDuringBattle && Plugin.InBattle) - || !Tab.UnhideOnActivity - ) - { - LastActivityTime = FrameTime; - return true; - } - - var lastActivityTime = Math.Max(Tab.LastActivity, LastActivityTime); - lastActivityTime = Math.Max(lastActivityTime, ChatLogWindow.LastActivityTime); - return FrameTime - lastActivityTime <= 1000 * Plugin.Config.InactivityHideTimeout; - } - - public override void PreDraw() - { - // Theme engine pushes the active theme globally in Plugin.Draw; - // pop-outs draw consistently without per-window overrides. - Flags = ImGuiWindowFlags.None; - if (!Plugin.Config.ShowPopOutTitleBar) - Flags |= ImGuiWindowFlags.NoTitleBar; - - if (!Tab.CanMove) - Flags |= ImGuiWindowFlags.NoMove; - - if (!Tab.CanResize) - Flags |= ImGuiWindowFlags.NoResize; - - // Guard against Idx pointing past the end if PopOutDocked was resized mid-frame. - if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count && !ChatLogWindow.PopOutDocked[Idx]) - { - BgAlpha = Tab.IndependentOpacity ? Tab.Opacity / 100f : Plugin.Config.WindowOpacity; - } - } - - public override void Draw() - { - using var id = ImRaii.PushId($"popout-{Tab.Identifier}"); - - if (!Plugin.Config.ShowPopOutTitleBar) - { - ImGui.TextUnformatted(Tab.Name); - ImGui.Separator(); - } - - var hintBannerHeight = DrawHintBannerIfNeeded(); - - // Toggle-OFF resets InputBar so the next toggle-ON starts with a fresh buffer. - var inputEnabled = Plugin.Config.PopOutInputEnabled; - if (!inputEnabled && InputBar != null) - InputBar = null; - - if (inputEnabled) - InputBar ??= new ChatInputBar(ChatLogWindow.Plugin, ChatLogWindow, () => Tab); - - var inputBarHeight = inputEnabled - ? ImGui.GetFrameHeightWithSpacing() + ImGui.GetStyle().ItemSpacing.Y - : 0f; - - var handler = ChatLogWindow.HandlerLender.Borrow(); - var logHeight = ImGui.GetContentRegionAvail().Y - inputBarHeight - hintBannerHeight; - ChatLogWindow.DrawMessageLog(Tab, handler, logHeight, false, updateScrollState: false); - - if (inputEnabled && InputBar != null) - { - ImGui.Separator(); - InputBar.RenderCompact(); - } - - if (ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows)) - LastActivityTime = FrameTime; - } - - // Returns the vertical space consumed by the banner (0 when not shown). - private float DrawHintBannerIfNeeded() - { - if (Plugin.Config.SeenPopOutInputHint) - return 0f; - - var hintText = Resources.HellionStrings.Popout_v060_HintText; - var ackLabel = Resources.HellionStrings.Popout_v060_HintAck; - var openLabel = Resources.HellionStrings.Popout_v060_HintOpenSettings; - - var startY = ImGui.GetCursorPosY(); - - var bg = new System.Numerics.Vector4(0.16f, 0.20f, 0.28f, 1f); - ImGui.PushStyleColor(ImGuiCol.ChildBg, bg); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1f); - - var dismiss = false; - var openSettings = false; - using ( - var child = ImRaii.Child( - "##v060-pop-out-hint", - new System.Numerics.Vector2(0f, 64f), - true - ) - ) - { - if (child) - { - ImGui.TextWrapped(hintText); - if (ImGui.Button(ackLabel)) - dismiss = true; - ImGui.SameLine(); - if (ImGui.Button(openLabel)) - { - dismiss = true; - openSettings = true; - } - } - } - - ImGui.PopStyleVar(); - ImGui.PopStyleColor(); - ImGui.Spacing(); - - if (dismiss) - { - Plugin.Config.SeenPopOutInputHint = true; - ChatLogWindow.Plugin.SaveConfig(); - _logger.LogDebug("Pop-Out input hint dismissed"); - if (openSettings) - ChatLogWindow.Plugin.SettingsWindow.Toggle(); - } - - return ImGui.GetCursorPosY() - startY; - } - - public override void PostDraw() - { - if (Idx >= 0 && Idx < ChatLogWindow.PopOutDocked.Count) - ChatLogWindow.PopOutDocked[Idx] = ImGui.IsWindowDocked(); - } - - public override void OnClose() - { - ChatLogWindow.PopOutWindows.Remove(Tab.Identifier); - ChatLogWindow.Plugin.WindowSystem.RemoveWindow(this); - - Tab.PopOut = false; - ChatLogWindow.Plugin.SaveConfig(); - } - - private enum HideState - { - None, - Cutscene, - CutsceneOverride, - User, - Battle, - } - - private HideState CurrentHideState = HideState.None; - - private bool HideStateCheck() - { - if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle) - { - CurrentHideState = HideState.Battle; - _logger.LogTrace($"Popout HideState [{Tab.Name}]: None -> Battle"); - } - - if (CurrentHideState is HideState.Battle && !Plugin.InBattle) - { - CurrentHideState = HideState.None; - _logger.LogTrace($"Popout HideState [{Tab.Name}]: Battle -> None"); - } - - if ( - Tab.HideDuringCutscenes - && CurrentHideState == HideState.None - && (Plugin.CutsceneActive || Plugin.GposeActive) - ) - { - if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags()) - { - CurrentHideState = HideState.Cutscene; - _logger.LogTrace($"Popout HideState [{Tab.Name}]: None -> Cutscene"); - } - } - - if ( - CurrentHideState is HideState.Cutscene or HideState.CutsceneOverride - && !Plugin.CutsceneActive - && !Plugin.GposeActive - ) - { - _logger.LogTrace( - $"Popout HideState [{Tab.Name}]: {CurrentHideState} -> None (cutscene/gpose ended)" - ); - CurrentHideState = HideState.None; - } - - if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate) - { - CurrentHideState = HideState.CutsceneOverride; - _logger.LogTrace( - $"Popout HideState [{Tab.Name}]: Cutscene -> CutsceneOverride (user activate)" - ); - } - - if (CurrentHideState == HideState.User && ChatLogWindow.Activate) - { - CurrentHideState = HideState.None; - _logger.LogTrace($"Popout HideState [{Tab.Name}]: User -> None (activate)"); - } - - return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle - || (Tab.HideWhenNotLoggedIn && !Plugin.ClientState.IsLoggedIn); - } -} diff --git a/HellionChat/Ui/Settings.cs b/HellionChat/Ui/Settings.cs deleted file mode 100755 index 5ce19f8..0000000 --- a/HellionChat/Ui/Settings.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System.Numerics; -using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Utility.Raii; -using Dalamud.Interface.Windowing; -using Dalamud.Utility; -using HellionChat.Resources; -using HellionChat.Ui.SettingsTabs; -using HellionChat.Util; -using Microsoft.Extensions.Logging; - -namespace HellionChat.Ui; - -internal enum SettingsView -{ - Overview, - Detail, -} - -public sealed class SettingsWindow : Dalamud.Interface.Windowing.Window -{ - internal readonly Plugin Plugin; - - private Configuration Mutable { get; } - private List Tabs { get; } - private int CurrentTab; - private SettingsView View = SettingsView.Overview; - - // Set when a section is freshly entered; the first Draw afterwards reads it - // and clears it, so each section starts collapsed every time it is opened. - private bool _sectionJustEntered; - private readonly SettingsOverview Overview; - - internal SettingsWindow(Plugin plugin, ILoggerFactory loggerFactory) - : base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings") - { - Flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse; - - SizeCondition = ImGuiCond.FirstUseEver; - SizeConstraints = new WindowSizeConstraints - { - MinimumSize = new Vector2(475, 600), - MaximumSize = new Vector2(float.MaxValue, float.MaxValue), - }; - - Plugin = plugin; - Mutable = new Configuration(); - - Overview = new SettingsOverview(this); - - Tabs = - [ - new General(Plugin, Mutable), - new Appearance(Plugin, Mutable, loggerFactory.CreateLogger()), - new Chat(Plugin, Mutable), - new SettingsTabs.Window(Plugin, Mutable), - new SettingsTabs.Tabs(Plugin, Mutable), - new DataAndPrivacy(Plugin, Mutable, loggerFactory.CreateLogger()), - new About(Plugin, Mutable), - ]; - - RespectCloseHotkey = false; - DisableWindowSounds = true; - - Initialise(); - } - - public void Dispose() - { - // Slash-command + OpenConfigUi tear-down moved to Plugin.TearDownCommands. - } - - private void Initialise() - { - Mutable.UpdateFrom(Plugin.Config, false); - } - - public override void Draw() - { - if (ImGui.IsWindowAppearing()) - { - Initialise(); - View = SettingsView.Overview; - } - - // ESC in Detail view returns to Overview. Window focus check is - // required so ESC doesn't fire when the user targets a different window. - if ( - View == SettingsView.Detail - && ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows) - && ImGui.IsKeyPressed(ImGuiKey.Escape) - ) - { - View = SettingsView.Overview; - return; - } - - if (View == SettingsView.Overview) - Overview.Draw(); - else - DrawDetail(); - - ImGui.Separator(); - DrawSaveButtons(); - } - - internal void OpenSection(int tabIndex) - { - CurrentTab = tabIndex; - View = SettingsView.Detail; - _sectionJustEntered = true; - } - - internal void OpenOverview() - { - View = SettingsView.Overview; - } - - private void DrawDetail() - { - // Breadcrumb header -- accent cyan, clickable, returns to Overview. - using (ImRaii.PushColor(ImGuiCol.Text, 0xFF00BED2u)) - using (ImRaii.PushColor(ImGuiCol.Button, 0u)) - using (ImRaii.PushColor(ImGuiCol.ButtonHovered, 0x33FFFFFFu)) - using (ImRaii.PushColor(ImGuiCol.ButtonActive, 0x55FFFFFFu)) - { - if (ImGui.SmallButton("<- Settings")) - { - View = SettingsView.Overview; - return; - } - } - ImGui.SameLine(); - ImGui.TextUnformatted("·"); - ImGui.SameLine(); - ImGui.TextUnformatted(Tabs[CurrentTab].Name.Split("###")[0]); - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - // Section content fills full width. Navigation back to another - // section goes via the breadcrumb or ESC. - var style = ImGui.GetStyle(); - var height = - ImGui.GetContentRegionAvail().Y - - style.FramePadding.Y * 2 - - style.ItemSpacing.Y - - style.ItemInnerSpacing.Y * 2 - - ImGui.CalcTextSize("A").Y; - - using var child = ImRaii.Child("##chat2-settings-detail", new Vector2(-1, height)); - if (child.Success) - { - Tabs[CurrentTab].Draw(_sectionJustEntered); - _sectionJustEntered = false; - } - } - - private void DrawSaveButtons() - { - var save = ImGui.Button(Language.Settings_Save); - - ImGui.SameLine(); - - if (ImGui.Button(Language.Settings_SaveAndClose)) - { - save = true; - IsOpen = false; - } - - ImGui.SameLine(); - - if (ImGui.Button(Language.Settings_Discard)) - IsOpen = false; - - const string buttonLabel = "Anna's Ko-fi"; - const string buttonLabel2 = "Infi's Ko-fi"; - - using (ImRaii.PushColor(ImGuiCol.Button, ColourUtil.RgbaToAbgr(0xFF5E5BFF))) - using (ImRaii.PushColor(ImGuiCol.ButtonHovered, ColourUtil.RgbaToAbgr(0xFF7775FF))) - using (ImRaii.PushColor(ImGuiCol.ButtonActive, ColourUtil.RgbaToAbgr(0xFF4542FF))) - using (ImRaii.PushColor(ImGuiCol.Text, 0xFFFFFFFF)) - { - var buttonWidth = - ImGui.CalcTextSize(buttonLabel).X + ImGui.GetStyle().FramePadding.X * 2; - var buttonWidth2 = - ImGui.CalcTextSize(buttonLabel2).X + ImGui.GetStyle().FramePadding.X * 2; - ImGui.SameLine( - ImGui.GetContentRegionAvail().X - - buttonWidth - - buttonWidth2 - - ImGui.GetStyle().ItemSpacing.X - ); - - if (ImGui.Button(buttonLabel2)) - Plugin.PlatformUtil.OpenLink("https://ko-fi.com/infiii"); - - ImGui.SameLine(); - - if (ImGui.Button(buttonLabel)) - Plugin.PlatformUtil.OpenLink("https://ko-fi.com/lojewalo"); - } - - if (!save) - return; - - var hideChanged = !Mutable.HideChat && Mutable.HideChat != Plugin.Config.HideChat; - var languageChanged = Mutable.LanguageOverride != Plugin.Config.LanguageOverride; - - // v1.5.3: Auto-enable the ExtraGlyphRanges flag matching the new - // locale so non-Latin scripts render immediately. Without this, - // a user switching to Korean would see "===" until they manually - // tick the Korean range in Fonts & Colours. - if (languageChanged) - { - var required = Mutable.LanguageOverride.RequiredGlyphRanges(); - if (required != 0) - Mutable.ExtraGlyphRanges |= required; - } - - var fontChanged = - Mutable.GlobalFontV2 != Plugin.Config.GlobalFontV2 - || Mutable.JapaneseFontV2 != Plugin.Config.JapaneseFontV2 - || Mutable.ItalicFontV2 != Plugin.Config.ItalicFontV2 - || Mutable.ExtraGlyphRanges != Plugin.Config.ExtraGlyphRanges - || Mutable.UseHellionFont != Plugin.Config.UseHellionFont; - var fontSizeChanged = - Math.Abs(Mutable.SymbolsFontSizeV2 - Plugin.Config.SymbolsFontSizeV2) > 0.001 - || Math.Abs(Mutable.FontSizeV2 - Plugin.Config.FontSizeV2) > 0.001; - var italicStateChanged = Mutable.ItalicEnabled != Plugin.Config.ItalicEnabled; - - // Only refilter when filter-relevant settings changed. Clear+Refilter - // reloads from the DB and silently drops in-session messages that - // weren't persisted (Privacy-First blocks most channels). Cosmetic - // changes (theme, icons, layout) skip the cycle. - var filtersChanged = HasFilterRelevantChanges(); - - Plugin.Config.UpdateFrom(Mutable, true); - - // Defer save by 60 frames to avoid committing changes that cause a crash. - Plugin.DeferredSaveFrames = 60; - if (filtersChanged) - { - Plugin.MessageManager.ClearAllTabs(); - Plugin.MessageManager.FilterAllTabsAsync(); - } - - if (fontChanged || fontSizeChanged || italicStateChanged) - Plugin.FontManager.RebuildDelegateFonts(); - - if (languageChanged) - Plugin.LanguageChanged(Plugin.Interface.UiLanguage); - - if (hideChanged) - GameFunctions.GameFunctions.SetChatInteractable(true); - - if (Plugin.Config.ShowEmotes) - _ = EmoteCache.LoadData(); - - Initialise(); - } - - // Returns true if any filter-relevant setting changed between Plugin.Config - // and the Mutable copy. Gates Clear+Refilter on Save so cosmetic changes - // don't wipe in-session chat history. - private bool HasFilterRelevantChanges() - { - if (Mutable.PrivacyFilterEnabled != Plugin.Config.PrivacyFilterEnabled) - return true; - if (Mutable.PrivacyPersistUnknownChannels != Plugin.Config.PrivacyPersistUnknownChannels) - return true; - if (!Mutable.PrivacyPersistChannels.SetEquals(Plugin.Config.PrivacyPersistChannels)) - return true; - - // FilterIncludePreviousSessions changes the GetMostRecentMessages - // window and is filter-relevant even outside the Privacy block. - if (Mutable.FilterIncludePreviousSessions != Plugin.Config.FilterIncludePreviousSessions) - return true; - - // Compare persistent tabs only -- TempTabs are never refiltered. - var origPersistent = Plugin.Config.Tabs.Where(t => !t.IsTempTab).ToList(); - var newPersistent = Mutable.Tabs.Where(t => !t.IsTempTab).ToList(); - - if (origPersistent.Count != newPersistent.Count) - return true; - - for (var i = 0; i < origPersistent.Count; i++) - { - var orig = origPersistent[i]; - var neu = newPersistent[i]; - - // Identifier mismatch means reorder or slot swap -- treat as filter-relevant. - if (orig.Identifier != neu.Identifier) - return true; - - if (orig.ExtraChatAll != neu.ExtraChatAll) - return true; - if (!orig.ExtraChatChannels.SetEquals(neu.ExtraChatChannels)) - return true; - - if (orig.SelectedChannels.Count != neu.SelectedChannels.Count) - return true; - foreach (var pair in orig.SelectedChannels) - { - if (!neu.SelectedChannels.TryGetValue(pair.Key, out var nv)) - return true; - if (!pair.Value.Equals(nv)) - return true; - } - } - - return false; - } -} diff --git a/HellionChat/Ui/SettingsOverview.cs b/HellionChat/Ui/SettingsOverview.cs deleted file mode 100644 index 88fda98..0000000 --- a/HellionChat/Ui/SettingsOverview.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Numerics; -using Dalamud.Bindings.ImGui; -using Dalamud.Interface; -using Dalamud.Interface.Utility.Raii; -using HellionChat.Resources; -using HellionChat.Util; - -namespace HellionChat.Ui; - -internal sealed class SettingsOverview -{ - private readonly SettingsWindow _window; - - // Card order matches the Tabs index in SettingsWindow 1:1. - private static (FontAwesomeIcon Icon, string Title, string Subtext)[] BuildCardDefs() => - [ - ( - FontAwesomeIcon.SlidersH, - HellionStrings.Settings_Card_General_Title, - HellionStrings.Settings_Card_General_Subtext - ), - ( - FontAwesomeIcon.Palette, - HellionStrings.Settings_Card_Appearance_Title, - HellionStrings.Settings_Card_Appearance_Subtext - ), - ( - FontAwesomeIcon.Comments, - HellionStrings.Settings_Card_Chat_Title, - HellionStrings.Settings_Card_Chat_Subtext - ), - ( - FontAwesomeIcon.WindowMaximize, - HellionStrings.Settings_Card_Window_Title, - HellionStrings.Settings_Card_Window_Subtext - ), - ( - FontAwesomeIcon.FolderTree, - HellionStrings.Settings_Card_Tabs_Title, - HellionStrings.Settings_Card_Tabs_Subtext - ), - ( - FontAwesomeIcon.Database, - HellionStrings.Settings_Card_DataManagement_Title, - HellionStrings.Settings_Card_DataManagement_Subtext - ), - ( - FontAwesomeIcon.InfoCircle, - HellionStrings.Settings_Card_Information_Title, - HellionStrings.Settings_Card_Information_Subtext - ), - ]; - - public SettingsOverview(SettingsWindow window) - { - _window = window; - } - - public void Draw() - { - var avail = ImGui.GetContentRegionAvail(); - var columns = avail.X >= 700f ? 3 : 2; - var cardWidth = (avail.X - (columns - 1) * 8f) / columns; - // 110f accommodates two-line subtexts; wrap width is matched in DrawCard. - var cardHeight = 110f; - - // One draw-list lookup per frame instead of one per card. - var drawList = ImGui.GetWindowDrawList(); - var cardDefs = BuildCardDefs(); - for (var i = 0; i < cardDefs.Length; i++) - { - var (icon, title, subtext) = cardDefs[i]; - DrawCard(i, icon, title, subtext, cardWidth, cardHeight, drawList); - - if ((i + 1) % columns != 0 && i != cardDefs.Length - 1) - ImGui.SameLine(); - } - } - - private void DrawCard( - int index, - FontAwesomeIcon icon, - string title, - string subtext, - float w, - float h, - ImDrawListPtr drawList - ) - { - // BeginGroup makes the card a single layout item so SameLine works - // in the caller loop -- without it ImGui tracks each child separately. - ImGui.BeginGroup(); - - var cursorBefore = ImGui.GetCursorScreenPos(); - var clicked = ImGui.InvisibleButton($"##settings-card-{index}", new Vector2(w, h)); - var hovered = ImGui.IsItemHovered(); - var bgColor = hovered ? 0xFF22303Fu : 0xFF1A2538u; - - drawList.AddRectFilled(cursorBefore, cursorBefore + new Vector2(w, h), bgColor, 4f); - - var iconPos = cursorBefore + new Vector2(16f, 12f); - var titlePos = cursorBefore + new Vector2(16f, 40f); - var subtextPos = cursorBefore + new Vector2(16f, 62f); - - var titleColor = ColourUtil.RgbaToAbgr(0xE6F4F1FFu); - var subtextColor = ColourUtil.RgbaToAbgr(0x8FA3B5FFu); - - using (_window.Plugin.FontManager.FontAwesome.Push()) - { - drawList.AddText(iconPos, titleColor, icon.ToIconString()); - } - - drawList.AddText(titlePos, titleColor, title); - - // Subtext wraps at card inner width (16px padding each side) via DrawList - // to avoid expanding the group bounds and breaking SameLine in the card row. - var subtextWrapWidth = w - 32f; - drawList.AddText( - ImGui.GetFont(), - ImGui.GetFontSize(), - subtextPos, - subtextColor, - subtext, - subtextWrapWidth - ); - - ImGui.EndGroup(); - - if (clicked) - _window.OpenSection(index); - } -} diff --git a/HellionChat/Ui/SettingsTabs/About.cs b/HellionChat/Ui/SettingsTabs/About.cs deleted file mode 100644 index c20581a..0000000 --- a/HellionChat/Ui/SettingsTabs/About.cs +++ /dev/null @@ -1,493 +0,0 @@ -using System.Numerics; -using Dalamud.Bindings.ImGui; -using Dalamud.Interface; -using Dalamud.Interface.Colors; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Raii; -using HellionChat.Branding; -using HellionChat.Integrations; -using HellionChat.Resources; -using HellionChat.Util; - -namespace HellionChat.Ui.SettingsTabs; - -// The About tab absorbs the former Integrations tab (now the first section) -// and organises its remaining content into four thematic sections. -internal sealed class About : ISettingsTab -{ - private Plugin Plugin { get; } - private Configuration Mutable { get; } - - public string Name => HellionStrings.Settings_Tab_Information + "###tabs-information"; - - private readonly List Translators = - [ - "q673135110", - "Akizem", - "d0tiKs", - "Moonlight_Everlit", - "Dark32", - "andreycout", - "Button_", - "Cali666", - "cassandra308", - "lokinmodar", - "jtabox", - "AkiraYorumoto", - "MKhayle", - "elena.space", - "imlisa", - "andrei5125", - "ShivaMaheshvara", - "aislinn87", - "nishinatsu051", - "lichuyuan", - "Risu64", - "yummypillow", - "witchymary", - "Yuzumi", - "zomsakura", - "Sirayuki", - ]; - - internal About(Plugin plugin, Configuration mutable) - { - Plugin = plugin; - Mutable = mutable; - Translators.Sort( - (a, b) => - string.Compare(a.ToLowerInvariant(), b.ToLowerInvariant(), StringComparison.Ordinal) - ); - } - - public void Draw(bool sectionJustEntered) - { - using var wrap = ImRaii.TextWrapPos(0.0f); - - DrawExtensionsSection(sectionJustEntered); - ImGui.Spacing(); - DrawPluginInfoSection(sectionJustEntered); - ImGui.Spacing(); - DrawProjectSection(sectionJustEntered); - ImGui.Spacing(); - DrawTranslatorsSection(sectionJustEntered); - ImGui.Spacing(); - DrawChangelogSection(sectionJustEntered); - } - - // ── Extensions ────────────────────────────────────────────────────────── - - private void DrawExtensionsSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Extensions); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.TextWrapped(HellionStrings.Settings_Integrations_Intro); - ImGui.Spacing(); - ImGui.Spacing(); - - DrawHonorificSection(); - ImGui.Spacing(); - ImGui.Spacing(); - - DrawComingSoonSection(); - ImGui.Spacing(); - ImGui.Spacing(); - - DrawGotAnIdeaSection(); - } - } - - private void DrawHonorificSection() - { - DrawSectionHeader(HellionStrings.Settings_Integrations_Honorific_SectionHeader); - - DrawHonorificStatus(); - ImGui.Spacing(); - - // Toggle works regardless of detection state: "show when available, - // hide otherwise". Disabling it when Honorific is missing would force - // the user to retoggle on every reload. - if ( - ImGui.Checkbox( - HellionStrings.Settings_Integrations_Honorific_Toggle, - ref Mutable.ShowHonorificTitleInHeader - ) - ) - { - Plugin.SaveConfig(); - } - - using (ImRaii.PushIndent()) - { - using ( - ImRaii.PushColor( - ImGuiCol.Text, - ColourUtil.RgbaToAbgr(Plugin.ThemeRegistry.Active.Colors.TextMuted) - ) - ) - { - ImGui.TextWrapped(HellionStrings.Settings_Integrations_Honorific_ToggleHint); - } - - if ( - ImGui.Checkbox( - HellionStrings.Settings_Integrations_Honorific_Glow_Toggle, - ref Mutable.ShowHonorificGlow - ) - ) - { - Plugin.SaveConfig(); - } - ImGuiUtil.HelpMarker(HellionStrings.Settings_Integrations_Honorific_Glow_Hint); - } - - // Honorific has no LICENSE in its repo so we link upstream and author - // instead of bundling assets. Text labels because FA Brands isn't - // guaranteed in Dalamud's font set. - ImGui.Spacing(); - if (ImGui.Button(HellionStrings.Settings_Integrations_Honorific_LinkRepo)) - { - Plugin.PlatformUtil.OpenLink(IntegrationLinks.HonorificRepo); - } - ImGui.SameLine(); - if (ImGui.Button(HellionStrings.Settings_Integrations_Honorific_LinkAuthor)) - { - Plugin.PlatformUtil.OpenLink(IntegrationLinks.HonorificAuthor); - } - } - - private void DrawHonorificStatus() - { - var theme = Plugin.ThemeRegistry.Active; - var service = Plugin.HonorificService; - - if (service.IsAvailable && service.DetectedApiVersion is { } version) - { - DrawStatusGlyph('●', theme.Colors.StatusSuccess); - ImGui.SameLine(); - ImGui.TextUnformatted( - string.Format( - HellionStrings.Settings_Integrations_Honorific_Status_Detected, - version.Major, - version.Minor - ) - ); - } - else if (service.DetectedApiVersion is { } incompatibleVersion) - { - DrawStatusGlyph('⚠', theme.Colors.StatusWarning); - ImGui.SameLine(); - ImGui.TextUnformatted( - string.Format( - HellionStrings.Settings_Integrations_Honorific_Status_Incompatible, - HonorificService.ExpectedApiMajor, - incompatibleVersion.Major, - incompatibleVersion.Minor - ) - ); - } - else - { - DrawStatusGlyph('○', theme.Colors.TextMuted); - ImGui.SameLine(); - ImGui.TextUnformatted( - HellionStrings.Settings_Integrations_Honorific_Status_NotInstalled - ); - } - } - - private static void DrawStatusGlyph(char glyph, uint rgba) - { - using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(rgba))) - { - ImGui.TextUnformatted(glyph.ToString()); - } - } - - private void DrawComingSoonSection() - { - DrawSectionHeader(HellionStrings.Settings_Integrations_ComingSoon_SectionHeader); - ImGui.TextWrapped(HellionStrings.Settings_Integrations_ComingSoon_Intro); - ImGui.Spacing(); - - // Each integration cycle removes its stub here and adds a full section above. - DrawComingSoonItem( - HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Title, - HellionStrings.Settings_Integrations_ComingSoon_ContextMenu_Description - ); - DrawComingSoonItem( - HellionStrings.Settings_Integrations_ComingSoon_Notifications_Title, - HellionStrings.Settings_Integrations_ComingSoon_Notifications_Description - ); - DrawComingSoonItem( - HellionStrings.Settings_Integrations_ComingSoon_RPStatus_Title, - HellionStrings.Settings_Integrations_ComingSoon_RPStatus_Description - ); - DrawComingSoonItem( - HellionStrings.Settings_Integrations_ComingSoon_ExtraChat_Title, - HellionStrings.Settings_Integrations_ComingSoon_ExtraChat_Description - ); - DrawComingSoonItem( - HellionStrings.Settings_Integrations_ComingSoon_QuickDM_Title, - HellionStrings.Settings_Integrations_ComingSoon_QuickDM_Description - ); - } - - private void DrawComingSoonItem(string title, string description) - { - var theme = Plugin.ThemeRegistry.Active; - using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted))) - using (Plugin.FontManager.FontAwesome.Push()) - { - ImGui.TextUnformatted(FontAwesomeIcon.Hourglass.ToIconString()); - } - ImGui.SameLine(); - ImGui.TextUnformatted(title); - using (ImRaii.PushIndent()) - { - using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.TextMuted))) - { - ImGui.TextWrapped(description); - } - } - ImGui.Spacing(); - } - - private void DrawGotAnIdeaSection() - { - DrawSectionHeader(HellionStrings.Settings_Integrations_GotAnIdea_SectionHeader); - ImGui.TextWrapped(HellionStrings.Settings_Integrations_GotAnIdea_Body); - ImGui.Spacing(); - - if (ImGui.Button(HellionStrings.Settings_Integrations_GotAnIdea_LinkLabel)) - { - Plugin.PlatformUtil.OpenLink(BrandingLinks.HellionForgeDiscordInvite); - } - } - - private void DrawSectionHeader(string label) - { - var theme = Plugin.ThemeRegistry.Active; - using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(theme.Colors.Primary))) - { - ImGui.TextUnformatted("── " + label + " ──"); - } - } - - // ── Plugin info ────────────────────────────────────────────────────────── - - private void DrawPluginInfoSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_PluginInfo); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - DrawFoxBanner(); - ImGuiHelpers.ScaledDummy(6.0f); - - ImGui.TextUnformatted(string.Format(Language.Options_About_Opening, Plugin.PluginName)); - - ImGuiHelpers.ScaledDummy(10.0f); - - ImGui.TextUnformatted(Language.Options_About_Authors); - ImGui.SameLine(); - ImGui.TextColored(ImGuiColors.ParsedGold, Plugin.Interface.Manifest.Author); - - ImGui.TextUnformatted(Language.Options_About_Discord); - ImGui.SameLine(); - ImGui.TextColored(ImGuiColors.ParsedGold, "@j.j_kazama"); - - ImGui.TextUnformatted(Language.Options_About_Version); - ImGui.SameLine(); - ImGui.TextColored( - ImGuiColors.ParsedOrange, - Plugin.Interface.Manifest.AssemblyVersion.ToString(3) - ); - - ImGuiHelpers.ScaledDummy(10.0f); - - ImGui.TextUnformatted(Language.Options_About_Github_Issues); - ImGui.SameLine(); - if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "githubIssues")) - Plugin.PlatformUtil.OpenLink( - "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/issues" - ); - } - } - - private void DrawFoxBanner() - { - var banner = FoxBannerTexture.Shared.GetWrapOrDefault(); - if (banner is null) - return; - - const uint CardColor = 0xFFE8E8E8; // off-white fill so the dark fox pops - var imgHeight = 170f * ImGuiHelpers.GlobalScale; - var imgWidth = imgHeight * banner.Size.X / banner.Size.Y; - var pad = 14f * ImGuiHelpers.GlobalScale; - var cardWidth = imgWidth + pad * 2f; - var cardHeight = imgHeight + pad * 2f; - var rounding = 8f * ImGuiHelpers.GlobalScale; - - // Left-aligned: card origin stays at the current layout cursor position. - var cardOrigin = ImGui.GetCursorScreenPos(); - - // Draw the rounded card behind the image, then place the image on top. - ImGui - .GetWindowDrawList() - .AddRectFilled( - cardOrigin, - cardOrigin + new Vector2(cardWidth, cardHeight), - CardColor, - rounding - ); - ImGui.SetCursorScreenPos(cardOrigin + new Vector2(pad, pad)); - ImGui.Image(banner.Handle, new Vector2(imgWidth, imgHeight)); - - // Advance the layout cursor past the full card so content below does not overlap. - ImGui.SetCursorScreenPos(cardOrigin); - ImGui.Dummy(new Vector2(cardWidth, cardHeight)); - } - - // ── The Project ────────────────────────────────────────────────────────── - - private void DrawProjectSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Project); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Maintainer_Heading); - ImGui.TextUnformatted(HellionStrings.About_Maintainer_Body); - ImGui.TextUnformatted(HellionStrings.About_Maintainer_Website_Label); - ImGui.SameLine(); - if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "hellionMedia")) - Plugin.PlatformUtil.OpenLink("https://hellion-media.de"); - - ImGuiHelpers.ScaledDummy(10.0f); - - ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Mission_Heading); - ImGui.TextUnformatted(HellionStrings.About_Mission_P1); - ImGui.Spacing(); - ImGui.TextUnformatted(HellionStrings.About_Mission_P2); - ImGui.Spacing(); - ImGui.TextUnformatted(HellionStrings.About_Mission_P3); - - ImGuiHelpers.ScaledDummy(10.0f); - - ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_BuiltOn_Heading); - ImGui.TextUnformatted(HellionStrings.About_BuiltOn_P1); - ImGui.Spacing(); - ImGui.TextUnformatted(HellionStrings.About_BuiltOn_P2); - ImGui.Spacing(); - ImGui.TextUnformatted(HellionStrings.About_BuiltOn_Upstream_Label); - ImGui.SameLine(); - if (ImGuiUtil.IconButton(FontAwesomeIcon.ExternalLinkAlt, "chatTwoUpstream")) - Plugin.PlatformUtil.OpenLink("https://github.com/Infiziert90/ChatTwo"); - - ImGuiHelpers.ScaledDummy(10.0f); - - ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_License_Heading); - ImGui.TextUnformatted(HellionStrings.About_License_P1); - ImGui.TextUnformatted(HellionStrings.About_License_P2); - ImGui.TextUnformatted(HellionStrings.About_License_P3); - - ImGuiHelpers.ScaledDummy(10.0f); - - ImGui.TextColored(ImGuiColors.DalamudOrange, HellionStrings.About_SE_Heading); - ImGui.TextUnformatted(HellionStrings.About_SE_P1); - ImGui.TextUnformatted(HellionStrings.About_SE_P2); - - ImGui.Spacing(); - - ImGui.TextColored(ImGuiColors.ParsedGold, HellionStrings.About_Localization_Heading); - ImGui.TextUnformatted(HellionStrings.About_Localization_P1); - ImGui.TextUnformatted(HellionStrings.About_Localization_P2); - } - } - - // ── Translators ────────────────────────────────────────────────────────── - - private void DrawTranslatorsSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Translators); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - // The translator list belongs to the Chat 2 upstream Crowdin project. - using var translatorTree = ImRaii.TreeNode(HellionStrings.About_Translators_TreeNode); - if (translatorTree) - { - using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); - foreach (var translator in Translators) - ImGui.TextUnformatted(translator); - } - } - } - - // ── Changelog ──────────────────────────────────────────────────────────── - - private void DrawChangelogSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Changelog); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_PrintChangelog_Name, ref Mutable.PrintChangelog); - ImGuiUtil.HelpMarker(Language.Options_PrintChangelog_Description); - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - var changelog = Plugin.Interface.Manifest.Changelog; - if (changelog == null) - return; - - ImGui.TextUnformatted(Language.Options_Changelog_Header); - ImGui.TextUnformatted( - $"Version {Plugin.Interface.Manifest.AssemblyVersion.ToString(3)}" - ); - ImGui.Spacing(); - foreach (var sentence in changelog.Split("\n")) - { - if (sentence == string.Empty) - { - ImGui.NewLine(); - continue; - } - - var indented = sentence.StartsWith('-') || sentence.StartsWith(" -"); - using var indent = ImRaii.PushIndent(10.0f, true, indented); - ImGui.TextUnformatted(sentence); - } - } - } -} diff --git a/HellionChat/Ui/SettingsTabs/Appearance.cs b/HellionChat/Ui/SettingsTabs/Appearance.cs deleted file mode 100644 index aa3044c..0000000 --- a/HellionChat/Ui/SettingsTabs/Appearance.cs +++ /dev/null @@ -1,695 +0,0 @@ -using System.Numerics; -using Dalamud; -using Dalamud.Bindings.ImGui; -using Dalamud.Interface; -using Dalamud.Interface.FontIdentifier; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Raii; -using HellionChat.Code; -using HellionChat.Resources; -using HellionChat.Themes; -using HellionChat.Util; -using Microsoft.Extensions.Logging; - -namespace HellionChat.Ui.SettingsTabs; - -internal sealed class Appearance : ISettingsTab -{ - private Plugin Plugin { get; } - private Configuration Mutable { get; } - private readonly ILogger _logger; - - private string? _applyDismissedFor; - - public string Name => HellionStrings.Settings_Tab_Appearance + "###tabs-appearance"; - - internal Appearance(Plugin plugin, Configuration mutable, ILogger logger) - { - Plugin = plugin; - Mutable = mutable; - _logger = logger; - } - - public void Draw(bool sectionJustEntered) - { - DrawThemeSection(sectionJustEntered); - ImGui.Spacing(); - DrawFontsSection(sectionJustEntered); - ImGui.Spacing(); - DrawColoursSection(sectionJustEntered); - ImGui.Spacing(); - DrawWindowStyleSection(sectionJustEntered); - ImGui.Spacing(); - DrawTimestampSection(sectionJustEntered); - ImGui.Spacing(); - DrawAnimationsSection(sectionJustEntered); - } - - // ── Theme ────────────────────────────────────────────────────────────── - - private void DrawThemeSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Theme); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - var registry = Plugin.ThemeRegistry; - var active = registry.Get(Mutable.Theme); - - ImGui.TextUnformatted( - string.Format(HellionStrings.Settings_Themes_Active, active.Name) - ); - using (ImRaii.PushColor(ImGuiCol.Text, 0xFF8FA3B5u)) - ImGui.TextUnformatted(active.Author); - - DrawChatColorsApplyBanner(active); - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - ImGui.TextUnformatted(HellionStrings.Settings_Themes_BuiltIns); - ImGui.Spacing(); - DrawThemeGrid(registry.AllBuiltIns(), active.Slug); - - var customs = registry.AllCustom().ToList(); - if (customs.Count > 0) - { - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - ImGui.TextUnformatted(HellionStrings.Settings_Themes_Custom); - ImGui.Spacing(); - DrawThemeGrid(customs, active.Slug); - } - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - if (ImGui.Button(HellionStrings.Settings_Themes_OpenFolder)) - { - var dir = Path.Combine(Plugin.Interface.ConfigDirectory.FullName, "themes"); - Directory.CreateDirectory(dir); - Plugin.PlatformUtil.OpenLink(dir); - } - - ImGui.SameLine(); - if (ImGui.Button(HellionStrings.Settings_Themes_ExportActive)) - { - var dir = Path.Combine(Plugin.Interface.ConfigDirectory.FullName, "themes"); - Directory.CreateDirectory(dir); - var fileName = $"{active.Slug}.export.json"; - var path = Path.Combine(dir, fileName); - var json = ThemeJsonWriter.Serialize(active); - File.WriteAllText(path, json); - _logger.LogInformation($"Exported active theme '{active.Slug}' to {path}"); - } - } - } - - private void DrawThemeGrid(IEnumerable themes, string activeSlug) - { - var avail = ImGui.GetContentRegionAvail(); - var columns = avail.X >= 700f ? 3 : 2; - var cardWidth = (avail.X - (columns - 1) * 8f) / columns; - var cardHeight = 140f; - - var list = themes.ToList(); - for (var i = 0; i < list.Count; i++) - { - DrawThemeCard(list[i], activeSlug, cardWidth, cardHeight); - - if ((i + 1) % columns != 0 && i != list.Count - 1) - ImGui.SameLine(); - } - } - - private void DrawThemeCard(Theme theme, string activeSlug, float w, float h) - { - ImGui.BeginGroup(); - - var isActive = string.Equals(theme.Slug, activeSlug, StringComparison.OrdinalIgnoreCase); - var cursorBefore = ImGui.GetCursorScreenPos(); - var clicked = ImGui.InvisibleButton($"##theme-card-{theme.Slug}", new Vector2(w, h)); - var hovered = ImGui.IsItemHovered(); - - var draw = ImGui.GetWindowDrawList(); - var bg = ColourUtil.RgbaToAbgr(theme.Colors.WindowBg | 0xFFu); - draw.AddRectFilled(cursorBefore, cursorBefore + new Vector2(w, h), bg, 4f); - - if (isActive) - { - var border = ColourUtil.RgbaToAbgr(theme.Colors.Primary); - draw.AddRect( - cursorBefore, - cursorBefore + new Vector2(w, h), - border, - 4f, - ImDrawFlags.None, - 2f - ); - } - else if (hovered) - { - var border = ColourUtil.RgbaToAbgr(theme.Colors.PrimaryLight & 0xFFFFFF99u); - draw.AddRect( - cursorBefore, - cursorBefore + new Vector2(w, h), - border, - 4f, - ImDrawFlags.None, - 1f - ); - } - - var mockupOrigin = cursorBefore + new Vector2(12f, 12f); - var mockupSize = new Vector2(w - 24f, 60f); - ThemeMockup.Draw(mockupOrigin, mockupSize, theme); - - var textColor = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); - var mutedColor = ColourUtil.RgbaToAbgr(theme.Colors.TextMuted); - draw.AddText(cursorBefore + new Vector2(12f, 80f), textColor, theme.Name); - draw.AddText(cursorBefore + new Vector2(12f, 100f), mutedColor, theme.Author); - - ImGui.EndGroup(); - - if (clicked) - { - Mutable.Theme = theme.Slug; - Plugin.ThemeRegistry.Switch(theme.Slug); - _applyDismissedFor = null; - } - } - - private void DrawChatColorsApplyBanner(Theme active) - { - if (active.ChatColors is not { Channels.Count: > 0 } themeChatColors) - return; - - if (_applyDismissedFor == active.Slug) - return; - - var alreadyMatching = themeChatColors.Channels.All(kvp => - Mutable.ChatColours.TryGetValue(kvp.Key, out var current) && current == kvp.Value - ); - if (alreadyMatching) - return; - - ImGui.Spacing(); - - var border = ColourUtil.RgbaToAbgr(active.Colors.Primary); - var bgFill = ColourUtil.RgbaToAbgr((active.Colors.Surface & 0xFFFFFF00u) | 0xCCu); - var origin = ImGui.GetCursorScreenPos(); - var width = ImGui.GetContentRegionAvail().X; - var height = 64f; - var draw = ImGui.GetWindowDrawList(); - draw.AddRectFilled(origin, origin + new Vector2(width, height), bgFill, 4f); - draw.AddRect(origin, origin + new Vector2(width, height), border, 4f, ImDrawFlags.None, 1f); - - var textColor = ColourUtil.RgbaToAbgr(active.Colors.TextPrimary); - draw.AddText( - origin + new Vector2(12f, 10f), - textColor, - HellionStrings.Settings_Themes_ApplyChatColors_Hint - ); - - using (ImRaii.PushColor(ImGuiCol.Button, active.Colors.Primary)) - using (ImRaii.PushColor(ImGuiCol.ButtonHovered, active.Colors.PrimaryLight)) - using (ImRaii.PushColor(ImGuiCol.ButtonActive, active.Colors.PrimaryDark)) - { - ImGui.SetCursorScreenPos(origin + new Vector2(12f, 32f)); - if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Apply)) - { - foreach (var kvp in themeChatColors.Channels) - Mutable.ChatColours[kvp.Key] = kvp.Value; - _applyDismissedFor = active.Slug; - } - } - - ImGui.SameLine(); - if (ImGui.Button(HellionStrings.Settings_Themes_ApplyChatColors_Keep)) - { - _applyDismissedFor = active.Slug; - } - - ImGui.SetCursorScreenPos(origin + new Vector2(0f, height + 8f)); - - ImGui.Spacing(); - } - - // ── Fonts ────────────────────────────────────────────────────────────── - // R3 deliberately NOT applied here — the UseHellionFont/FontsEnabled - // visibility chain has priority over type grouping (R4). - - private void DrawFontsSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Fonts); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - if ( - ImGui.Checkbox(HellionStrings.Theme_UseHellionFont_Name, ref Mutable.UseHellionFont) - ) - { - if (Mutable.UseHellionFont) - Mutable.FontsEnabled = false; - } - ImGuiUtil.HelpMarker(HellionStrings.Theme_UseHellionFont_Description); - ImGui.Spacing(); - - if (Mutable.UseHellionFont) - { - // Bundled-font path: only the base font size matters; the - // global / japanese / italic chooser pickers do not apply. - ImGuiUtil.FontSizeCombo(Language.Options_FontSize_Name, ref Mutable.FontSizeV2); - ImGui.Spacing(); - } - else - { - ImGui.Checkbox(Language.Options_FontsEnabled, ref Mutable.FontsEnabled); - ImGui.Spacing(); - } - - var unused = false; - if (!Mutable.UseHellionFont && !Mutable.FontsEnabled) - { - ImGuiUtil.FontSizeCombo(Language.Options_FontSize_Name, ref Mutable.FontSizeV2); - } - else if (!Mutable.UseHellionFont) - { - var globalChooser = ImGuiUtil.FontChooser( - Language.Options_Font_Name, - Mutable.GlobalFontV2, - false, - ref unused - ); - globalChooser?.ResultTask.ContinueWith(r => - { - if (r.IsCompletedSuccessfully) - { - Plugin.Framework.Run(() => Mutable.GlobalFontV2 = r.Result); - } - }); - ImGui.SameLine(); - if (ImGui.Button("Reset##global")) - { - Mutable.GlobalFontV2 = new SingleFontSpec - { - FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), - SizePt = 12.75f, - }; - } - - ImGuiUtil.HelpMarker( - string.Format(Language.Options_Font_Description, Plugin.PluginName) - ); - ImGuiUtil.WarningText(Language.Options_Font_Warning); - ImGui.Spacing(); - - var japaneseChooser = ImGuiUtil.FontChooser( - Language.Options_JapaneseFont_Name, - Mutable.JapaneseFontV2, - false, - ref unused, - id => !id.LocaleNames?.ContainsKey("ja-jp") ?? false, - "いろはにほへと ちりぬるを" - ); - japaneseChooser?.ResultTask.ContinueWith(r => - { - if (r.IsCompletedSuccessfully) - { - Plugin.Framework.Run(() => Mutable.JapaneseFontV2 = r.Result); - } - }); - ImGui.SameLine(); - if (ImGui.Button("Reset##japanese")) - { - Mutable.JapaneseFontV2 = new SingleFontSpec - { - FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkMedium), - SizePt = 12.75f, - }; - } - - ImGuiUtil.HelpMarker( - string.Format(Language.Options_JapaneseFont_Description, Plugin.PluginName) - ); - ImGui.Spacing(); - - var italicChooser = ImGuiUtil.FontChooser( - Language.Options_ItalicFont_Name, - Mutable.ItalicFontV2, - true, - ref Mutable.ItalicEnabled - ); - italicChooser?.ResultTask.ContinueWith(r => - { - if (r.IsCompletedSuccessfully) - { - Plugin.Framework.Run(() => Mutable.ItalicFontV2 = r.Result); - } - }); - ImGui.SameLine(); - if (ImGui.Button("Reset##italic")) - { - Mutable.ItalicEnabled = false; - Mutable.ItalicFontV2 = new SingleFontSpec - { - FontId = new DalamudAssetFontAndFamilyId(DalamudAsset.NotoSansCjkRegular), - SizePt = 12.75f, - }; - } - - ImGuiUtil.HelpMarker( - string.Format(Language.Options_Italic_Description, Plugin.PluginName) - ); - ImGui.Spacing(); - } - - // v1.5.3: ExtraGlyphRanges is an atlas-wide property and stays - // reachable regardless of UseHellionFont / FontsEnabled state so - // users can verify or override the auto-activation on language change. - ImGui.Spacing(); - if (ImGui.CollapsingHeader(Language.Options_ExtraGlyphs_Name)) - { - ImGuiUtil.HelpMarker( - string.Format(Language.Options_ExtraGlyphs_Description, Plugin.PluginName) - ); - - var range = (int)Mutable.ExtraGlyphRanges; - foreach (var extra in Enum.GetValues()) - { - ImGui.CheckboxFlags(extra.Name(), ref range, (int)extra); - } - - Mutable.ExtraGlyphRanges = (ExtraGlyphRanges)range; - } - - ImGuiUtil.FontSizeCombo( - Language.Options_SymbolsFontSize_Name, - ref Mutable.SymbolsFontSizeV2 - ); - ImGuiUtil.HelpMarker(Language.Options_SymbolsFontSize_Description); - - ImGui.Spacing(); - } - } - - // ── Colours ──────────────────────────────────────────────────────────── - - private void DrawColoursSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Colours); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - DrawColourPresetButtons(); - ImGui.TextDisabled(HellionStrings.Settings_Appearance_Colours_PresetsHint); - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - ImGui.Checkbox( - Language.Options_ColorSelectedInputChannelButton_Name, - ref Mutable.ColorSelectedInputChannelButton - ); - ImGuiUtil.HelpMarker(Language.Options_ColorSelectedInputChannelButton_Description); - ImGui.Spacing(); - - foreach (var (_, types) in ChatTypeExt.SortOrder) - { - foreach (var type in types) - { - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.UndoAlt, - $"{type}", - Language.Options_ChatColours_Reset - ) - ) - { - Mutable.ChatColours.Remove(type); - } - - ImGui.SameLine(); - - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.LongArrowAltDown, - $"{type}", - Language.Options_ChatColours_Import - ) - ) - { - var gameColour = Plugin.Functions.Chat.GetChannelColor(type); - Mutable.ChatColours[type] = gameColour ?? type.DefaultColor() ?? 0; - } - - ImGui.SameLine(); - - var vec = Mutable.ChatColours.TryGetValue(type, out var colour) - ? ColourUtil.RgbaToVector3(colour) - : ColourUtil.RgbaToVector3(type.DefaultColor() ?? 0); - if (ImGui.ColorEdit3(type.Name(), ref vec, ImGuiColorEditFlags.NoInputs)) - { - Mutable.ChatColours[type] = ColourUtil.Vector3ToRgba(vec); - } - } - } - - ImGui.Spacing(); - } - } - - private void DrawColourPresetButtons() - { - var first = true; - foreach (var (_, preset) in ChatColourPresets.All) - { - if (!first) - { - ImGui.SameLine(); - } - first = false; - - if (preset.IsBrandPreset) - { - var border = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(255, 128, 200)); - var btn = ColourUtil.RgbaToVector3(ColourUtil.ComponentsToRgba(74, 42, 106)); - ImGui.PushStyleColor( - ImGuiCol.Border, - new System.Numerics.Vector4(border.X, border.Y, border.Z, 1f) - ); - ImGui.PushStyleColor( - ImGuiCol.Button, - new System.Numerics.Vector4(btn.X, btn.Y, btn.Z, 1f) - ); - ImGui.PushStyleVar(ImGuiStyleVar.FrameBorderSize, 1.5f); - } - - if (ImGui.Button(GetPresetLabel(preset))) - { - ApplyPreset(preset); - } - - if (preset.IsBrandPreset) - { - ImGui.PopStyleVar(); - ImGui.PopStyleColor(2); - } - } - } - - private static string GetPresetLabel(ChatColourPreset preset) - { - var localized = HellionStrings.ResourceManager.GetString( - preset.LocalizationKey, - HellionStrings.Culture - ); - return string.IsNullOrEmpty(localized) ? preset.DisplayName : localized; - } - - private void ApplyPreset(ChatColourPreset preset) - { - foreach (var (channel, colour) in preset.Colours) - { - Mutable.ChatColours[channel] = colour; - } - Plugin.SaveConfig(); - GlobalParametersCache.Refresh(); - _logger.LogDebug($"Applied chat colour preset: {preset.DisplayName}"); - } - - // ── Window style ─────────────────────────────────────────────────────── - - private void DrawWindowStyleSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_WindowStyle); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_ShowTitleBar_Name, ref Mutable.ShowTitleBar); - - ImGui.Checkbox( - Language.Options_ShowPopOutTitleBar_Name, - ref Mutable.ShowPopOutTitleBar - ); - - ImGui.Checkbox(Language.Options_ShowHideButton_Name, ref Mutable.ShowHideButton); - ImGuiUtil.HelpMarker(Language.Options_ShowHideButton_Description); - - ImGui.Checkbox(Language.Options_SidebarTabView_Name, ref Mutable.SidebarTabView); - ImGuiUtil.HelpMarker( - string.Format(Language.Options_SidebarTabView_Description, Plugin.PluginName) - ); - - if (Mutable.SidebarTabView) - { - var sidebarWidth = Mutable.SidebarWidth; - if ( - ImGui.SliderInt( - HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Name, - ref sidebarWidth, - 44, - 160, - $"{sidebarWidth} px" - ) - ) - { - Mutable.SidebarWidth = sidebarWidth; - } - ImGuiUtil.HelpMarker( - HellionStrings.Settings_ThemeAndLayout_SidebarWidth_Description - ); - } - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - // Slider range 50-100% maps to 0.5-1.0 internally. Floor at 50% prevents - // accidentally hiding the chat background (v1.2.0 bug at WindowAlpha=0). - var opacityPercent = Mutable.WindowOpacity * 100f; - if ( - ImGuiUtil.DragFloatVertical( - HellionStrings.Settings_ThemeAndLayout_WindowOpacity_Name, - ref opacityPercent, - .25f, - 50f, - 100f, - $"{opacityPercent:N0}%%", - ImGuiSliderFlags.AlwaysClamp - ) - ) - { - Mutable.WindowOpacity = opacityPercent / 100f; - } - ImGuiUtil.HelpMarker(HellionStrings.Settings_ThemeAndLayout_WindowOpacity_Description); - - // UI-12: inactive-window opacity, same 50-100% range and clamp. - var inactiveOpacityPercent = Mutable.WindowOpacityInactive * 100f; - if ( - ImGuiUtil.DragFloatVertical( - HellionStrings.Settings_ThemeAndLayout_WindowOpacityInactive_Name, - ref inactiveOpacityPercent, - .25f, - 50f, - 100f, - $"{inactiveOpacityPercent:N0}%%", - ImGuiSliderFlags.AlwaysClamp - ) - ) - { - Mutable.WindowOpacityInactive = inactiveOpacityPercent / 100f; - } - ImGuiUtil.HelpMarker( - HellionStrings.Settings_ThemeAndLayout_WindowOpacityInactive_Description - ); - } - } - - // ── Timestamps ───────────────────────────────────────────────────────── - - private void DrawTimestampSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Timestamps); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox( - Language.Options_PrettierTimestamps_Name, - ref Mutable.PrettierTimestamps - ); - ImGuiUtil.HelpMarker(Language.Options_PrettierTimestamps_Description); - - if (Mutable.PrettierTimestamps) - { - ImGui.Checkbox( - Language.Options_MoreCompactPretty_Name, - ref Mutable.MoreCompactPretty - ); - ImGuiUtil.HelpMarker(Language.Options_MoreCompactPretty_Description); - - ImGui.Checkbox( - HellionStrings.Appearance_UseCompactDensity_Name, - ref Mutable.UseCompactDensity - ); - ImGuiUtil.HelpMarker(HellionStrings.Appearance_UseCompactDensity_Description); - - ImGui.Checkbox( - Language.Options_HideSameTimestamps_Name, - ref Mutable.HideSameTimestamps - ); - ImGuiUtil.HelpMarker(Language.Options_HideSameTimestamps_Description); - } - - ImGui.Checkbox(Language.Options_Use24HourClock_Name, ref Mutable.Use24HourClock); - ImGuiUtil.HelpMarker(Language.Options_Use24HourClock_Description); - } - } - - // ── Animations ───────────────────────────────────────────────────────── - - private void DrawAnimationsSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Animations); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - // Master accessibility toggle for the v1.5.4 motion work: the - // theme crossfade, the sidebar/card hover lerps and the - // unread-tab pulse all read Config.ReduceMotion and snap - // instantly when it is on. - ImGui.Checkbox( - HellionStrings.Settings_ThemeAndLayout_ReduceMotion_Name, - ref Mutable.ReduceMotion - ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_ThemeAndLayout_ReduceMotion_Description); - } - } -} diff --git a/HellionChat/Ui/SettingsTabs/Chat.cs b/HellionChat/Ui/SettingsTabs/Chat.cs deleted file mode 100644 index 0561af3..0000000 --- a/HellionChat/Ui/SettingsTabs/Chat.cs +++ /dev/null @@ -1,423 +0,0 @@ -using System.Numerics; -using Dalamud.Bindings.ImGui; -using Dalamud.Interface; -using Dalamud.Interface.Colors; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Raii; -using HellionChat.Resources; -using HellionChat.Util; - -namespace HellionChat.Ui.SettingsTabs; - -// Six sections: Messages, Input & preview, Auto-tell tabs, Emotes, Links & tooltips, Novice network. -internal sealed class Chat : ISettingsTab -{ - private Plugin Plugin { get; } - private Configuration Mutable { get; } - - public string Name => HellionStrings.Settings_Tab_Chat + "###tabs-chat"; - - private SearchSelector.SelectorPopupOptions WordPopupOptions; - - // Tracks which EmoteCache state WordPopupOptions was built for so we - // don't refill every frame when FilteredSheet is empty. - private EmoteCache.LoadingState? WordPopupOptionsBuiltFor; - - internal Chat(Plugin plugin, Configuration mutable) - { - Plugin = plugin; - Mutable = mutable; - - WordPopupOptions = RefillSheet(); - WordPopupOptionsBuiltFor = EmoteCache.State; - } - - private SearchSelector.SelectorPopupOptions RefillSheet() => - new SearchSelector.SelectorPopupOptions - { - FilteredSheet = EmoteCache - .SortedCodeArray.Where(w => !Mutable.BlockedEmotes.Contains(w)) - .ToArray(), - }; - - public void Draw(bool sectionJustEntered) - { - DrawMessagesSection(sectionJustEntered); - ImGui.Spacing(); - DrawInputPreviewSection(sectionJustEntered); - ImGui.Spacing(); - DrawAutoTellTabsSection(sectionJustEntered); - ImGui.Spacing(); - DrawEmotesSection(sectionJustEntered); - ImGui.Spacing(); - DrawLinksTooltipsSection(sectionJustEntered); - ImGui.Spacing(); - DrawNoviceNetworkSection(sectionJustEntered); - } - - private void DrawMessagesSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Messages); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - // Checkboxes first. - ImGui.Checkbox( - Language.Options_CollapseDuplicateMessages_Name, - ref Mutable.CollapseDuplicateMessages - ); - ImGuiUtil.HelpMarker(Language.Options_CollapseDuplicateMessages_Description); - - // Conditional child: only visible when parent is on (R4). - if (Mutable.CollapseDuplicateMessages) - { - ImGui.Checkbox( - Language.Options_CollapseDuplicateMsgUniqueLink_Name, - ref Mutable.CollapseKeepUniqueLinks - ); - ImGuiUtil.HelpMarker(Language.Options_CollapseDuplicateMsgUniqueLink_Description); - } - - ImGui.Checkbox( - HellionStrings.Settings_Chat_NotifyFailedTell_Name, - ref Mutable.NotifyFailedTell - ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyFailedTell_Description); - - ImGui.Checkbox( - HellionStrings.Settings_Chat_NotifyPluginDisclosure_Name, - ref Mutable.NotifyPluginDisclosure - ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NotifyPluginDisclosure_Description); - - // Dropdowns after checkboxes (R3). - // UI-7: name display options. - using ( - var combo = ImGuiUtil.BeginComboVertical( - HellionStrings.Settings_Chat_WorldSuffix_Name, - Mutable.WorldSuffixMode.Name() - ) - ) - { - if (combo.Success) - { - foreach (var mode in Enum.GetValues()) - { - if (ImGui.Selectable(mode.Name(), Mutable.WorldSuffixMode == mode)) - Mutable.WorldSuffixMode = mode; - } - } - } - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_WorldSuffix_Description); - - using ( - var combo = ImGuiUtil.BeginComboVertical( - HellionStrings.Settings_Chat_NameForm_Name, - Mutable.NameFormMode.Name() - ) - ) - { - if (combo.Success) - { - foreach (var mode in Enum.GetValues()) - { - if (ImGui.Selectable(mode.Name(), Mutable.NameFormMode == mode)) - Mutable.NameFormMode = mode; - } - } - } - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_NameForm_Description); - } - } - - private void DrawInputPreviewSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_InputPreview); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - // Checkboxes first. - ImGui.Checkbox( - HellionStrings.Settings_Chat_SymbolPicker_Enable_Name, - ref Mutable.SymbolPickerEnabled - ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_Chat_SymbolPicker_Enable_Description); - - ImGui.Checkbox(Language.Options_PreviewOnlyIf_Name, ref Mutable.OnlyPreviewIf); - ImGuiUtil.HelpMarker(Language.Options_PreviewOnlyIf_Description); - - // Dropdown after checkboxes (R3). - using ( - var combo = ImGuiUtil.BeginComboVertical( - Language.Options_Preview_Name, - Mutable.PreviewPosition.Name() - ) - ) - { - if (combo) - { - foreach (var position in Enum.GetValues()) - { - if (ImGui.Selectable(position.Name(), Mutable.PreviewPosition == position)) - Mutable.PreviewPosition = position; - } - } - } - ImGuiUtil.HelpMarker(Language.Options_Preview_Description); - - // Number input last (R3). - if ( - ImGuiUtil.InputIntVertical( - Language.Options_PreviewMinimum_Name, - Language.Options_PreviewMinimum_Description, - ref Mutable.PreviewMinimum - ) - ) - Mutable.PreviewMinimum = Math.Clamp(Mutable.PreviewMinimum, 1, 250); - } - } - - private void DrawAutoTellTabsSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_AutoTellTabs); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - // Checkboxes first (R3). - ImGui.Checkbox( - HellionStrings.ChatLog_AutoTellTabs_Enable_Name, - ref Mutable.EnableAutoTellTabs - ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Enable_Description); - - ImGui.Checkbox( - HellionStrings.ChatLog_AutoTellTabs_Compact_Name, - ref Mutable.AutoTellTabsCompactDisplay - ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Compact_Description); - - ImGui.Checkbox( - HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Name, - ref Mutable.AutoTellTabsOpenAsPopout - ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_OpenAsPopout_Description); - - ImGui.Checkbox( - HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Name, - ref Mutable.AutoTellTabsShowGreetedToggle - ); - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_GreetedToggle_Description); - - // Sliders after checkboxes (R3). - ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale); - var limit = Mutable.AutoTellTabsLimit; - if (ImGui.SliderInt(HellionStrings.ChatLog_AutoTellTabs_Limit_Name, ref limit, 1, 50)) - Mutable.AutoTellTabsLimit = limit; - ImGuiUtil.HelpMarker(HellionStrings.ChatLog_AutoTellTabs_Limit_Description); - - ImGui.Spacing(); - ImGuiUtil.HelpText(HellionStrings.ChatLog_AutoTellTabs_PreloadHint); - - ImGui.Spacing(); - ImGuiUtil.WarningText(HellionStrings.ChatLog_AutoTellTabs_ConflictHint); - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - var preload = Mutable.AutoTellTabsHistoryPreload; - ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale); - if ( - ImGui.SliderInt( - HellionStrings.Privacy_AutoTellTabs_Preload_Name, - ref preload, - 0, - 100 - ) - ) - Mutable.AutoTellTabsHistoryPreload = preload; - ImGuiUtil.HelpMarker(HellionStrings.Privacy_AutoTellTabs_Preload_Description); - - ImGui.Spacing(); - ImGuiUtil.HelpText(HellionStrings.Privacy_AutoTellTabs_Preload_Hint); - } - } - - private void DrawEmotesSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Emotes); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - // Checkbox first (R3). - ImGui.Checkbox(Language.Options_ShowEmotes_Name, ref Mutable.ShowEmotes); - ImGuiUtil.HelpMarker(Language.Options_ShowEmotes_Desc); - - ImGui.Spacing(); - ImGui.TextUnformatted(Language.Options_Emote_BlockedEmotes); - ImGui.Spacing(); - - if ( - EmoteCache.State is EmoteCache.LoadingState.Done - && WordPopupOptions.FilteredSheet.Length == 0 - && WordPopupOptionsBuiltFor != EmoteCache.LoadingState.Done - ) - { - WordPopupOptions = RefillSheet(); - WordPopupOptionsBuiltFor = EmoteCache.LoadingState.Done; - } - - // Button to add blocked emotes (R3 — button before table). - var buttonWidth = ImGui.GetContentRegionAvail().X / 3; - using (Plugin.FontManager.FontAwesome.Push()) - ImGui.Button(FontAwesomeIcon.Plus.ToIconString(), new Vector2(buttonWidth, 0)); - - // OpenPopup on click because SelectorPopup uses ContextPopupItem - // which only triggers on right-click by default. - if (ImGui.IsItemClicked()) - ImGui.OpenPopup("WordAddPopup"); - - if (SearchSelector.SelectorPopup("WordAddPopup", out var newWord, WordPopupOptions)) - Mutable.BlockedEmotes.Add(newWord); - - using ( - var table = ImRaii.Table( - "##BlockedWords", - 2, - ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner - ) - ) - { - if (table) - { - ImGui.TableSetupColumn(Language.Options_Emote_EmoteTable); - ImGui.TableSetupColumn("##Del", ImGuiTableColumnFlags.WidthStretch, 0.07f); - ImGui.TableHeadersRow(); - - foreach (var word in Mutable.BlockedEmotes.ToArray()) - { - ImGui.TableNextColumn(); - ImGui.TextUnformatted(word); - - ImGui.TableNextColumn(); - if ( - ImGuiUtil.Button( - $"##{word}Del", - FontAwesomeIcon.Trash, - !ImGui.GetIO().KeyCtrl - ) - ) - Mutable.BlockedEmotes.Remove(word); - } - } - } - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - ImGui.TextUnformatted(Language.Options_Emote_EmoteStats); - ImGui.Spacing(); - - if (EmoteCache.State is EmoteCache.LoadingState.Done) - ImGui.TextColored(ImGuiColors.HealerGreen, Language.Options_Emote_Ready); - else - ImGui.TextColored(ImGuiColors.DPSRed, Language.Options_Emote_NotReady); - - ImGui.TextUnformatted( - $"{Language.Options_Emote_Loaded} {EmoteCache.SortedCodeArray.Length}" - ); - - // 5-column loaded-emotes display table. - using ( - var emoteTable = ImRaii.Table( - "##LoadedEmotes", - 5, - ImGuiTableFlags.RowBg | ImGuiTableFlags.BordersInner - ) - ) - { - if (emoteTable) - { - ImGui.TableSetupColumn("##word1"); - ImGui.TableSetupColumn("##word2"); - ImGui.TableSetupColumn("##word3"); - ImGui.TableSetupColumn("##word4"); - ImGui.TableSetupColumn("##word5"); - - foreach (var word in EmoteCache.SortedCodeArray) - { - ImGui.TableNextColumn(); - ImGui.TextUnformatted(word); - } - } - } - } - } - - private void DrawLinksTooltipsSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_LinksTooltips); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox( - Language.Options_NativeItemTooltips_Name, - ref Mutable.NativeItemTooltips - ); - ImGuiUtil.HelpMarker( - string.Format(Language.Options_NativeItemTooltips_Description, Plugin.PluginName) - ); - - // Conditional slider: only shown when native tooltips are enabled (R4). - if (Mutable.NativeItemTooltips) - { - ImGuiUtil.DragFloatVertical( - Language.Options_TooltipOffset_Name, - Language.Options_TooltipOffset_Desc, - ref Mutable.TooltipOffset, - 1, - 0f, - 400f, - $"{Mutable.TooltipOffset:N0}px", - ImGuiSliderFlags.AlwaysClamp - ); - } - } - } - - private void DrawNoviceNetworkSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_NoviceNetwork); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_ShowNoviceNetwork_Name, ref Mutable.ShowNoviceNetwork); - ImGuiUtil.HelpMarker(Language.Options_ShowNoviceNetwork_Description); - } - } -} diff --git a/HellionChat/Ui/SettingsTabs/DataAndPrivacy.cs b/HellionChat/Ui/SettingsTabs/DataAndPrivacy.cs deleted file mode 100644 index 0d2f390..0000000 --- a/HellionChat/Ui/SettingsTabs/DataAndPrivacy.cs +++ /dev/null @@ -1,1097 +0,0 @@ -using System.Diagnostics; -using Dalamud.Bindings.ImGui; -using Dalamud.Game.Text; -using Dalamud.Game.Text.SeStringHandling; -using Dalamud.Game.Text.SeStringHandling.Payloads; -using Dalamud.Interface.Colors; -using Dalamud.Interface.ImGuiNotification; -using Dalamud.Interface.Utility.Raii; -using HellionChat.Code; -using HellionChat.Export; -using HellionChat.Privacy; -using HellionChat.Resources; -using HellionChat.Util; -using Microsoft.Extensions.Logging; - -namespace HellionChat.Ui.SettingsTabs; - -internal sealed class DataAndPrivacy : ISettingsTab -{ - private Plugin Plugin { get; } - private Configuration Mutable { get; } - private readonly ILogger _logger; - - public string Name => - HellionStrings.Settings_Card_DataManagement_Title + "###tabs-datamanagement"; - - // Cleanup state - private Dictionary? CleanupCounts; - private long CleanupKeepCount; - private long CleanupDeleteCount; - private bool CleanupRunning; - private bool CleanupPreviewStale; - private HashSet? CleanupPreviewSnapshot; - private bool RetentionRunning => Plugin.RetentionSweepRunning; - - // Export form state - private int ExportRangeDays = 30; - private string ExportSenderSubstring = string.Empty; - private readonly HashSet ExportSelectedChannels = []; - private ExportFormat ExportFormat = ExportFormat.Markdown; - private bool ExportRunning; - - // DB-Viewer + Advanced state (was in Database.cs) - private bool ShowAdvanced; - private long DatabaseLastRefreshTicks; - private long DatabaseSize; - private long DatabaseLogSize; - private int DatabaseMessageCount; - - // Channel groupings shared by Cleanup-Breakdown, Retention and Export - // sections. Heading is resolved per-frame so a runtime LanguageChanged - // call updates the labels immediately. 1:1 from Privacy.cs Groups. - private static readonly (Func Heading, ChatType[] Types)[] Groups = - [ - ( - () => HellionStrings.Privacy_Group_DirectMessages, - [ChatType.TellIncoming, ChatType.TellOutgoing] - ), - ( - () => HellionStrings.Privacy_Group_PartyAlliance, - [ChatType.Party, ChatType.CrossParty, ChatType.Alliance, ChatType.PvpTeam] - ), - ( - () => HellionStrings.Privacy_Group_FreeCompany, - [ - ChatType.FreeCompany, - ChatType.FreeCompanyAnnouncement, - ChatType.FreeCompanyLoginLogout, - ] - ), - ( - () => HellionStrings.Privacy_Group_Linkshells, - [ - ChatType.Linkshell1, - ChatType.Linkshell2, - ChatType.Linkshell3, - ChatType.Linkshell4, - ChatType.Linkshell5, - ChatType.Linkshell6, - ChatType.Linkshell7, - ChatType.Linkshell8, - ] - ), - ( - () => HellionStrings.Privacy_Group_CrossLinkshells, - [ - ChatType.CrossLinkshell1, - ChatType.CrossLinkshell2, - ChatType.CrossLinkshell3, - ChatType.CrossLinkshell4, - ChatType.CrossLinkshell5, - ChatType.CrossLinkshell6, - ChatType.CrossLinkshell7, - ChatType.CrossLinkshell8, - ] - ), - ( - () => HellionStrings.Privacy_Group_ExtraChat, - [ - ChatType.ExtraChatLinkshell1, - ChatType.ExtraChatLinkshell2, - ChatType.ExtraChatLinkshell3, - ChatType.ExtraChatLinkshell4, - ChatType.ExtraChatLinkshell5, - ChatType.ExtraChatLinkshell6, - ChatType.ExtraChatLinkshell7, - ChatType.ExtraChatLinkshell8, - ] - ), - ( - () => HellionStrings.Privacy_Group_PublicChat, - [ - ChatType.Say, - ChatType.Shout, - ChatType.Yell, - ChatType.NoviceNetwork, - ChatType.CustomEmote, - ChatType.StandardEmote, - ] - ), - ( - () => HellionStrings.Privacy_Group_SystemLogs, - [ - ChatType.System, - ChatType.Notice, - ChatType.Urgent, - ChatType.Echo, - ChatType.NpcDialogue, - ChatType.NpcAnnouncement, - ChatType.LootNotice, - ChatType.LootRoll, - ChatType.RetainerSale, - ChatType.Crafting, - ChatType.Gathering, - ChatType.Sign, - ChatType.RandomNumber, - ] - ), - ]; - - internal DataAndPrivacy(Plugin plugin, Configuration mutable, ILogger logger) - { - Plugin = plugin; - Mutable = mutable; - _logger = logger; - } - - public void Draw(bool sectionJustEntered) - { - // Shift-on-open keeps the Advanced tools available without a permanent - // toggle in the UI, mirroring upstream Chat 2 behaviour. - if (sectionJustEntered) - ShowAdvanced = ImGui.GetIO().KeyShift; - - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - DrawPrivacyFilterSection(); - ImGui.Spacing(); - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - DrawStorageSection(); - ImGui.Spacing(); - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - DrawRetentionSection(); - ImGui.Spacing(); - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - DrawCleanupSection(); - ImGui.Spacing(); - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - DrawExportSection(); - ImGui.Spacing(); - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - DrawDatabaseSection(); - } - - private void DrawPrivacyFilterSection() - { - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_PrivacyFilter); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - // Wizard re-open sits outside the disabled block so it is always clickable. - if (ImGui.Button(HellionStrings.Wizard_Reopen_Button)) - Plugin.FirstRunWizard.IsOpen = true; - ImGui.Spacing(); - - ImGuiUtil.OptionCheckbox( - ref Mutable.PrivacyFilterEnabled, - HellionStrings.Privacy_FilterEnabled_Name, - HellionStrings.Privacy_FilterEnabled_Description - ); - ImGuiUtil.HelpMarker(HellionStrings.Privacy_FilterEnabled_StorageOnly_Help); - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - // Whitelist, presets, and PersistUnknown are greyed (still visible) - // when the filter is off — ImRaii.Disabled block preserved verbatim. - using (ImRaii.Disabled(!Mutable.PrivacyFilterEnabled)) - { - ImGuiUtil.HelpText(HellionStrings.Privacy_Whitelist_Help); - - ImGui.Spacing(); - - if (ImGui.Button(HellionStrings.Privacy_Preset_PrivacyFirst)) - Mutable.PrivacyPersistChannels = [.. PrivacyDefaults.PrivacyFirstWhitelist]; - - ImGui.SameLine(); - if (ImGui.Button(HellionStrings.Privacy_Preset_ClearAll)) - Mutable.PrivacyPersistChannels.Clear(); - - ImGui.SameLine(); - if (ImGui.Button(HellionStrings.Privacy_Preset_SelectAll)) - foreach (var group in Groups) - foreach (var t in group.Types) - Mutable.PrivacyPersistChannels.Add(t); - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - foreach (var (heading, types) in Groups) - { - using var groupTree = ImRaii.TreeNode(heading()); - if (!groupTree.Success) - continue; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - foreach (var type in types) - { - var enabled = Mutable.PrivacyPersistChannels.Contains(type); - var label = type.ToString(); - if (ImGui.Checkbox($"{label}##privacy-{(int)type}", ref enabled)) - { - if (enabled) - Mutable.PrivacyPersistChannels.Add(type); - else - Mutable.PrivacyPersistChannels.Remove(type); - } - } - } - } - - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - ImGuiUtil.OptionCheckbox( - ref Mutable.PrivacyPersistUnknownChannels, - HellionStrings.Privacy_PersistUnknown_Name, - HellionStrings.Privacy_PersistUnknown_Description - ); - } - } - } - - private void DrawStorageSection() - { - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Storage); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox( - Language.Options_DatabaseBattleMessages_Name, - ref Mutable.DatabaseBattleMessages - ); - ImGuiUtil.HelpMarker(Language.Options_DatabaseBattleMessages_Description); - - if ( - ImGui.Checkbox( - Language.Options_LoadPreviousSession_Name, - ref Mutable.LoadPreviousSession - ) - ) - if (Mutable.LoadPreviousSession) - Mutable.FilterIncludePreviousSessions = true; - ImGuiUtil.HelpMarker(Language.Options_LoadPreviousSession_Description); - - if ( - ImGui.Checkbox( - Language.Options_FilterIncludePreviousSessions_Name, - ref Mutable.FilterIncludePreviousSessions - ) - ) - if (!Mutable.FilterIncludePreviousSessions) - Mutable.LoadPreviousSession = false; - ImGuiUtil.HelpMarker(Language.Options_FilterIncludePreviousSessions_Description); - - var old = new FileInfo(Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat.db")); - var migratedOld = new FileInfo( - Path.Join(Plugin.Interface.ConfigDirectory.FullName, "chat-litedb.db") - ); - if (old.Exists || migratedOld.Exists) - { - ImGui.Spacing(); - ImGui.Separator(); - ImGui.Spacing(); - - ImGui.TextUnformatted(Language.Options_Database_Old_Heading); - ImGui.Spacing(); - - if ( - ImGuiUtil.CtrlShiftButton( - Language.Options_Database_Old_Delete, - Language.Options_Database_Old_Delete_Tooltip - ) - ) - { - try - { - if (old.Exists) - old.Delete(); - if (migratedOld.Exists) - migratedOld.Delete(); - WrapperUtil.AddNotification( - Language.Options_Database_Old_Delete_Success, - NotificationType.Success - ); - } - catch (Exception e) - { - _logger.LogError(e, "Unable to delete old database"); - WrapperUtil.AddNotification( - Language.Options_Database_Old_Delete_Error, - NotificationType.Error - ); - } - } - } - } - } - - private void DrawRetentionSection() - { - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Retention); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGuiUtil.OptionCheckbox( - ref Mutable.RetentionEnabled, - HellionStrings.Retention_Enabled_Name, - HellionStrings.Retention_Enabled_Description - ); - - using (ImRaii.Disabled(!Mutable.RetentionEnabled)) - { - ImGui.Spacing(); - - var defaultDays = Mutable.RetentionDefaultDays; - if (ImGui.InputInt(HellionStrings.Retention_Default_Label, ref defaultDays)) - Mutable.RetentionDefaultDays = Math.Max(0, defaultDays); - ImGuiUtil.HelpMarker(HellionStrings.Retention_Default_Help); - - ImGui.Spacing(); - - if (ImGui.Button(HellionStrings.Retention_Reset_Spec)) - { - Mutable.RetentionPerChannelDays = - PrivacyDefaults.DefaultRetentionDays.ToDictionary(p => p.Key, p => p.Value); - } - ImGui.SameLine(); - if (ImGui.Button(HellionStrings.Retention_Clear_Overrides)) - Mutable.RetentionPerChannelDays.Clear(); - - ImGui.Spacing(); - - using (var perChannelTree = ImRaii.TreeNode(HellionStrings.Retention_Tree_Heading)) - { - if (perChannelTree.Success) - { - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - foreach (var (heading, types) in Groups) - { - using var subTree = ImRaii.TreeNode(heading()); - if (!subTree.Success) - continue; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - foreach (var type in types) - { - var hasOverride = - Mutable.RetentionPerChannelDays.TryGetValue( - type, - out var days - ); - var hasSpecDefault = - PrivacyDefaults.DefaultRetentionDays.TryGetValue( - type, - out var specDays - ); - if (!hasOverride) - days = hasSpecDefault - ? specDays - : Mutable.RetentionDefaultDays; - - var tag = - hasOverride ? HellionStrings.Retention_Tag_Override - : hasSpecDefault ? HellionStrings.Retention_Tag_Spec - : HellionStrings.Retention_Tag_Global; - if ( - ImGui.InputInt( - $"{type} {tag}##retention-{(int)type}", - ref days - ) - ) - { - days = Math.Max(0, days); - Mutable.RetentionPerChannelDays[type] = days; - } - - if (hasOverride) - { - ImGui.SameLine(); - if ( - ImGui.Button( - $"{HellionStrings.Retention_Reset_Button}##retention-reset-{(int)type}" - ) - ) - Mutable.RetentionPerChannelDays.Remove(type); - } - } - } - } - } - - ImGui.Spacing(); - - ImGuiUtil.HelpText(HellionStrings.Retention_Help_SavedNote); - ImGui.Spacing(); - - using (ImRaii.Disabled(RetentionRunning)) - { - if ( - ImGuiUtil.CtrlShiftButton( - HellionStrings.Retention_Apply_Label, - HellionStrings.Retention_Apply_Tooltip - ) - ) - StartRetentionRun(); - } - - if (RetentionRunning) - ImGuiUtil.HelpText(HellionStrings.Retention_Running); - - ImGui.Spacing(); - var lastRun = Plugin.Config.RetentionLastRunAt; - ImGuiUtil.HelpText( - lastRun == DateTimeOffset.MinValue - ? HellionStrings.Retention_LastRun_Never - : string.Format(HellionStrings.Retention_LastRun_At, lastRun.ToLocalTime()) - ); - } - } - } - - private void StartRetentionRun() - { - lock (Plugin.RetentionSweepLock) - { - if (Plugin.RetentionSweepRunning) - return; - Plugin.RetentionSweepRunning = true; - } - - var policy = Plugin.Config.RetentionPerChannelDays.ToDictionary( - p => (int)(ushort)p.Key, - p => p.Value - ); - var defaultDays = Plugin.Config.RetentionDefaultDays; - - new Thread(() => - { - try - { - var deleted = Plugin.MessageManager.Store.DeleteByRetentionPolicy( - policy, - defaultDays - ); - Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow; - Plugin.SaveConfig(); - - _logger.LogInformation($"Manual retention run deleted {deleted} expired messages."); - - if (deleted > 0) - { - if ( - !Plugin - .Framework.Run(() => - { - Plugin.MessageManager.ClearAllTabs(); - Plugin.MessageManager.FilterAllTabsAsync(); - }) - .Wait(TimeSpan.FromSeconds(5)) - ) - { - _logger.LogWarning( - "Retention sweep: framework refresh timed out after 5s." - ); - } - } - - WrapperUtil.AddNotification( - string.Format(HellionStrings.Retention_Success, deleted), - NotificationType.Success - ); - } - catch (Exception e) - { - _logger.LogError(e, "Manual retention run failed"); - WrapperUtil.AddNotification(HellionStrings.Retention_Error, NotificationType.Error); - } - finally - { - lock (Plugin.RetentionSweepLock) - Plugin.RetentionSweepRunning = false; - } - }) - { - IsBackground = true, - }.Start(); - } - - private void DrawCleanupSection() - { - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Cleanup); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGuiUtil.HelpText(HellionStrings.Cleanup_Help_Intro); - ImGuiUtil.HelpText(HellionStrings.Cleanup_Help_SavedNote); - - ImGui.Spacing(); - - if ( - CleanupPreviewSnapshot is not null - && !CleanupPreviewSnapshot.SetEquals(Mutable.PrivacyPersistChannels) - ) - { - CleanupPreviewStale = true; - } - - using ( - var emphasis = CleanupPreviewStale - ? ImRaii.PushColor(ImGuiCol.Button, ImGuiColors.HealerGreen with { W = 0.6f }) - : null - ) - using (ImRaii.Disabled(CleanupRunning)) - { - if (ImGui.Button(HellionStrings.Cleanup_RefreshPreview)) - RefreshCleanupPreview(); - } - - if (CleanupCounts is null) - { - ImGuiUtil.HelpText(HellionStrings.Cleanup_NoPreview); - return; - } - - if (CleanupPreviewStale) - { - ImGui.Spacing(); - ImGuiUtil.HelpText(HellionStrings.Cleanup_Preview_Stale); - } - - ImGui.Spacing(); - - using ( - var staleColor = CleanupPreviewStale - ? ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey) - : null - ) - { - ImGuiUtil.HelpText( - string.Format( - HellionStrings.Cleanup_TotalStored, - CleanupKeepCount + CleanupDeleteCount - ) - ); - ImGuiUtil.HelpText( - string.Format(HellionStrings.Cleanup_WillKeep, CleanupKeepCount) - ); - ImGuiUtil.HelpText( - string.Format(HellionStrings.Cleanup_WillDelete, CleanupDeleteCount) - ); - } - - using (var breakdownTree = ImRaii.TreeNode(HellionStrings.Cleanup_Breakdown)) - { - if (breakdownTree.Success) - { - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - foreach ( - var (chatType, count) in CleanupCounts.OrderByDescending(p => p.Value) - ) - { - var name = Enum.IsDefined(typeof(ChatType), (ushort)chatType) - ? ((ChatType)(ushort)chatType).ToString() - : $"Unknown({chatType})"; - var keeps = WouldBeKept(chatType); - var marker = keeps - ? HellionStrings.Cleanup_Marker_Keep - : HellionStrings.Cleanup_Marker_Delete; - ImGuiUtil.HelpText($"{marker} {name} — {count:N0}"); - } - } - } - - ImGui.Spacing(); - - using (ImRaii.Disabled(CleanupRunning || CleanupDeleteCount == 0)) - { - if ( - ImGuiUtil.CtrlShiftButton( - HellionStrings.Cleanup_Apply_Label, - string.Format(HellionStrings.Cleanup_Apply_Tooltip, CleanupDeleteCount) - ) - ) - StartCleanup(); - } - - if (CleanupRunning) - ImGuiUtil.HelpText(HellionStrings.Cleanup_Running); - } - } - - private bool WouldBeKept(int chatType) - { - if (!Plugin.Config.PrivacyFilterEnabled) - return true; - if (Plugin.Config.PrivacyPersistChannels.Contains((ChatType)(ushort)chatType)) - return true; - return Plugin.Config.PrivacyPersistUnknownChannels; - } - - private void RefreshCleanupPreview() - { - try - { - CleanupCounts = Plugin.MessageManager.Store.GetMessageCountsByChatType(); - CleanupKeepCount = 0; - CleanupDeleteCount = 0; - foreach (var (chatType, count) in CleanupCounts) - { - if (WouldBeKept(chatType)) - CleanupKeepCount += count; - else - CleanupDeleteCount += count; - } - - CleanupPreviewSnapshot = new HashSet(Mutable.PrivacyPersistChannels); - CleanupPreviewStale = false; - } - catch (Exception e) - { - _logger.LogError(e, "Failed to compute cleanup preview"); - WrapperUtil.AddNotification( - HellionStrings.Cleanup_PreviewError, - NotificationType.Error - ); - } - } - - private void StartCleanup() - { - if (CleanupRunning) - return; - - CleanupRunning = true; - var allowed = Plugin.Config.PrivacyPersistChannels.Select(t => (int)(ushort)t).ToList(); - - var thread = new Thread(() => - { - try - { - var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed); - _logger.LogInformation($"Privacy cleanup: deleted {deleted} messages"); - - if ( - !Plugin - .Framework.Run(() => - { - Plugin.MessageManager.ClearAllTabs(); - Plugin.MessageManager.FilterAllTabs(); - }) - .Wait(TimeSpan.FromSeconds(5)) - ) - { - _logger.LogWarning("Privacy cleanup: framework refresh timed out after 5s."); - } - - WrapperUtil.AddNotification( - string.Format(HellionStrings.Cleanup_Success, deleted), - NotificationType.Success - ); - } - catch (Exception e) - { - _logger.LogError(e, "Privacy cleanup failed"); - WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error); - } - finally - { - CleanupRunning = false; - CleanupCounts = null; - } - }); - thread.IsBackground = true; - thread.Start(); - } - - private void DrawExportSection() - { - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Export); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGuiUtil.HelpText(HellionStrings.Export_Help); - - ImGui.Spacing(); - - if (ImGui.InputInt(HellionStrings.Export_Range_Label, ref ExportRangeDays)) - ExportRangeDays = Math.Max(0, ExportRangeDays); - - ImGui.InputText(HellionStrings.Export_Sender_Label, ref ExportSenderSubstring, 256); - - using (var channelsTree = ImRaii.TreeNode(HellionStrings.Export_Channels_Heading)) - { - if (channelsTree.Success) - { - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGuiUtil.HelpText(HellionStrings.Export_Channels_AllOff); - foreach (var (heading, types) in Groups) - { - using var subTree = ImRaii.TreeNode( - $"{heading()}##export-group-{heading()}" - ); - if (!subTree.Success) - continue; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - foreach (var type in types) - { - var enabled = ExportSelectedChannels.Contains(type); - if (ImGui.Checkbox($"{type}##export-{(int)type}", ref enabled)) - { - if (enabled) - ExportSelectedChannels.Add(type); - else - ExportSelectedChannels.Remove(type); - } - } - } - } - } - } - - ImGui.Spacing(); - ImGui.TextUnformatted(HellionStrings.Export_Format_Label); - ImGui.SameLine(); - var fmt = (int)ExportFormat; - if ( - ImGui.RadioButton( - HellionStrings.Export_Format_Markdown, - ref fmt, - (int)ExportFormat.Markdown - ) - ) - ExportFormat = ExportFormat.Markdown; - ImGui.SameLine(); - if ( - ImGui.RadioButton( - HellionStrings.Export_Format_Json, - ref fmt, - (int)ExportFormat.Json - ) - ) - ExportFormat = ExportFormat.Json; - ImGui.SameLine(); - if (ImGui.RadioButton(HellionStrings.Export_Format_Csv, ref fmt, (int)ExportFormat.Csv)) - ExportFormat = ExportFormat.Csv; - - ImGui.Spacing(); - - using (ImRaii.Disabled(ExportRunning)) - { - if (ImGui.Button(HellionStrings.Export_Button)) - PromptExport(); - } - - if (ExportRunning) - ImGuiUtil.HelpText(HellionStrings.Export_Running); - } - } - - private void PromptExport() - { - var defaultName = $"hellion-chat-export-{DateTimeOffset.Now:yyyyMMdd-HHmm}"; - var ext = ExportFormat.Extension(); - - Plugin.FileDialogManager.SaveFileDialog( - HellionStrings.Export_Dialog_Title, - ExportFormat.Filter(), - defaultName, - ext, - (success, path) => - { - if (!success || string.IsNullOrWhiteSpace(path)) - return; - StartExport(path); - } - ); - } - - private void StartExport(string path) - { - if (ExportRunning) - return; - ExportRunning = true; - - var types = - ExportSelectedChannels.Count > 0 - ? ExportSelectedChannels.Select(t => (int)(ushort)t).ToList() - : null; - - DateTimeOffset? from = - ExportRangeDays > 0 ? DateTimeOffset.UtcNow.AddDays(-ExportRangeDays) : null; - - var senderSubstring = string.IsNullOrWhiteSpace(ExportSenderSubstring) - ? null - : ExportSenderSubstring.Trim(); - var format = ExportFormat; - var filterDesc = new MessageExporter.FilterDescription(types, from, null, senderSubstring); - - new Thread(() => - { - try - { - using var enumerator = Plugin.MessageManager.Store.StreamForExport( - types, - from, - null - ); - var written = MessageExporter.ExportToFile(path, format, enumerator, filterDesc); - - if (written > 0) - WrapperUtil.AddNotification( - string.Format(HellionStrings.Export_Success, written, path), - NotificationType.Success - ); - else - WrapperUtil.AddNotification(HellionStrings.Export_Empty, NotificationType.Info); - } - catch (Exception e) - { - _logger.LogError(e, "Export failed"); - WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error); - } - finally - { - ExportRunning = false; - } - }) - { - IsBackground = true, - }.Start(); - } - - private void DrawDatabaseSection() - { - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Database); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - if (DatabaseLastRefreshTicks + 5 * 1000 < Environment.TickCount64) - { - DatabaseSize = Plugin.MessageManager.Store.DatabaseSize(); - DatabaseLogSize = Plugin.MessageManager.Store.DatabaseLogSize(); - DatabaseMessageCount = Plugin.MessageManager.Store.MessageCount(); - DatabaseLastRefreshTicks = Environment.TickCount64; - } - - ImGuiUtil.HelpText( - string.Format( - Language.Options_Database_Metadata_Path, - MessageManager.DatabasePath() - ) - ); - if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) - { - var path = Path.GetDirectoryName(MessageManager.DatabasePath()); - ImGui.SetClipboardText(path); - WrapperUtil.AddNotification( - Language.Options_Database_Metadata_CopyConfigPathNotification, - NotificationType.Info - ); - } - - if (ImGui.IsItemHovered()) - { - ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); - ImGuiUtil.Tooltip(Language.Options_Database_Metadata_CopyConfigPath); - } - - ImGuiUtil.HelpText( - string.Format( - Language.Options_Database_Metadata_Size, - StringUtil.BytesToString(DatabaseSize) - ) - ); - if (ImGui.IsItemHovered()) - ImGuiUtil.Tooltip(StringUtil.BytesToString(DatabaseSize)); - - ImGuiUtil.HelpText( - string.Format( - Language.Options_Database_Metadata_LogSize, - StringUtil.BytesToString(DatabaseLogSize) - ) - ); - if (ImGui.IsItemHovered()) - ImGuiUtil.Tooltip(StringUtil.BytesToString(DatabaseLogSize)); - - ImGuiUtil.HelpText( - string.Format(Language.Options_Database_Metadata_MessageCount, DatabaseMessageCount) - ); - - if ( - ImGuiUtil.CtrlShiftButton( - Language.Options_ClearDatabase_Button, - Language.Options_ClearDatabase_Tooltip - ) - ) - { - _logger.LogWarning("Clearing messages from database"); - Plugin.MessageManager.Store.ClearMessages(); - Plugin.MessageManager.ClearAllTabs(); - - DatabaseLastRefreshTicks = 0; - WrapperUtil.AddNotification( - Language.Options_ClearDatabase_Success, - NotificationType.Info - ); - } - - // Advanced sub-block: only visible when the tab was opened with Shift held. - // Gate matches the Shift-on-open flag set at the top of Draw(). - if (!ShowAdvanced) - return; - - ImGui.Spacing(); - using var advTree = ImRaii.TreeNode( - HellionStrings.Settings_DataManagement_Advanced_Heading - ); - if (!advTree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - using var wrap = ImRaii.TextWrapPos(0.0f); - - ImGuiUtil.WarningText(Language.Options_Database_Advanced_Warning); - if ( - ImGuiUtil.CtrlShiftButton( - "Perform maintenance", - "Ctrl+Shift: MessageManager.Store.PerformMaintenance()" - ) - ) - Plugin.MessageManager.Store.PerformMaintenance(); - - if ( - ImGuiUtil.CtrlShiftButton( - "Reload messages from database", - "Ctrl+Shift: MessageManager.FilterAllTabs()" - ) - ) - { - Plugin.MessageManager.ClearAllTabs(); - Plugin.MessageManager.FilterAllTabsAsync(); - } - - if ( - ImGuiUtil.CtrlShiftButton( - "Inject 10,000 messages", - "Ctrl+Shift: creates 10,000 unique messages (async)" - ) - ) - new Thread(() => InsertMessages(10_000)).Start(); - } - } - } - - private void InsertMessages(int count) - { - _logger.LogInformation($"Inserting {count} messages due to user request"); - - var stopwatch = Stopwatch.StartNew(); - var playerName = Plugin.PlayerState.CharacterName; - var worldId = Plugin.PlayerState.HomeWorld.ValueNullable?.RowId ?? 0; - var senderSource = new SeStringBuilder() - .AddText("<") - .Add(new PlayerPayload(playerName, worldId)) - .AddText("Random Message") - .Add(RawPayload.LinkTerminator) - .AddText(">: ") - .Build(); - var senderChunks = ChunkUtil - .ToChunks(senderSource, ChunkSource.Sender, ChatType.Debug) - .ToList(); - var messages = new List(count); - for (var i = 0; i < count; i++) - { - var contentSource = new SeStringBuilder() - .AddText("Random message payload - ") - .AddItalics(Guid.NewGuid().ToString()) - .Build(); - var contentChunks = ChunkUtil - .ToChunks(contentSource, ChunkSource.Content, ChatType.Debug) - .ToList(); - - var chatCode = new ChatCode(XivChatType.Say, 0, 0); - messages.Add( - new Message( - Guid.NewGuid(), - Plugin.MessageManager.CurrentContentId, - Plugin.MessageManager.CurrentContentId, - DateTimeOffset.UtcNow, - chatCode, - senderChunks, - contentChunks, - senderSource, - contentSource, - Guid.Empty - ) - ); - } - - var elapsedTicks = stopwatch.ElapsedTicks; - stopwatch.Stop(); - _logger.LogInformation( - $"Crafted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" - ); - - stopwatch = Stopwatch.StartNew(); - foreach (var message in messages) - Plugin.MessageManager.Store.UpsertMessage(message); - - elapsedTicks = stopwatch.ElapsedTicks; - stopwatch.Stop(); - _logger.LogInformation( - $"Upserted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" - ); - - Plugin - .Framework.Run(() => - { - stopwatch = Stopwatch.StartNew(); - Plugin.MessageManager.ClearAllTabs(); - elapsedTicks = stopwatch.ElapsedTicks; - stopwatch.Stop(); - _logger.LogInformation( - $"Cleared {Plugin.Config.Tabs.Count} tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" - ); - }) - .Wait(); - - Plugin - .Framework.Run(() => - { - stopwatch = Stopwatch.StartNew(); - Plugin.MessageManager.FilterAllTabs(); - elapsedTicks = stopwatch.ElapsedTicks; - stopwatch.Stop(); - _logger.LogInformation( - $"Fetched and filtered all tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)" - ); - }) - .Wait(); - } -} diff --git a/HellionChat/Ui/SettingsTabs/General.cs b/HellionChat/Ui/SettingsTabs/General.cs deleted file mode 100644 index 927be98..0000000 --- a/HellionChat/Ui/SettingsTabs/General.cs +++ /dev/null @@ -1,217 +0,0 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Utility; -using Dalamud.Interface.Utility.Raii; -using HellionChat.Resources; -using HellionChat.Util; - -namespace HellionChat.Ui.SettingsTabs; - -internal sealed class General : ISettingsTab -{ - private Plugin Plugin { get; } - private Configuration Mutable { get; } - - public string Name => HellionStrings.Settings_Tab_General + "###tabs-general"; - - internal General(Plugin plugin, Configuration mutable) - { - Plugin = plugin; - Mutable = mutable; - } - - public void Draw(bool sectionJustEntered) - { - DrawInputSection(sectionJustEntered); - ImGui.Spacing(); - DrawSoundSection(sectionJustEntered); - ImGui.Spacing(); - DrawLanguageSection(sectionJustEntered); - ImGui.Spacing(); - DrawPerformanceSection(sectionJustEntered); - } - - private void DrawInputSection(bool sectionJustEntered) - { - // Collapse every time the tab is freshly entered so state doesn't bleed across sessions. - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Input); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_KeepInputFocus_Name, ref Mutable.KeepInputFocus); - ImGuiUtil.HelpMarker(Language.Options_KeepInputFocus_Description); - - ImGui.Spacing(); - ImGui.TextUnformatted(Language.Options_ChatTabForwardKeybind_Name); - ImGui.SetNextItemWidth(-1); - ImGuiUtil.KeybindInput("ChatTabForwardKeybind", ref Mutable.ChatTabForward); - - ImGui.TextUnformatted(Language.Options_ChatTabBackwardKeybind_Name); - ImGui.SetNextItemWidth(-1); - ImGuiUtil.KeybindInput("ChatTabBackwardKeybind", ref Mutable.ChatTabBackward); - - ImGui.Spacing(); - - using ( - var combo = ImGuiUtil.BeginComboVertical( - Language.Options_KeybindMode_Name, - Mutable.KeybindMode.Name() - ) - ) - { - if (combo.Success) - { - foreach (var mode in Enum.GetValues()) - { - if (ImGui.Selectable(mode.Name(), Mutable.KeybindMode == mode)) - { - Mutable.KeybindMode = mode; - } - - if (ImGui.IsItemHovered()) - { - ImGuiUtil.Tooltip(mode.Tooltip() ?? ""); - } - } - } - } - ImGuiUtil.HelpMarker( - string.Format(Language.Options_KeybindMode_Description, Plugin.PluginName) - ); - } - } - - private void DrawSoundSection(bool sectionJustEntered) - { - // Collapse every time the tab is freshly entered so state doesn't bleed across sessions. - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Sound); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_PlaySounds_Name, ref Mutable.PlaySounds); - ImGuiUtil.HelpMarker(Language.Options_PlaySounds_Description); - // Volume is stored as a 0-1 float but shown as 0-100% to match user - // intuition. Full range — unlike opacity there is no unsafe floor. - var customSoundVolumePercent = Mutable.CustomSoundVolume * 100f; - if ( - ImGuiUtil.DragFloatVertical( - HellionStrings.Settings_General_CustomSoundVolume_Name, - ref customSoundVolumePercent, - 1f, - 0f, - 100f, - $"{customSoundVolumePercent:N0}%%", - ImGuiSliderFlags.AlwaysClamp - ) - ) - { - Mutable.CustomSoundVolume = customSoundVolumePercent / 100f; - } - // Show the functional description and the per-tab navigation hint together. - ImGuiUtil.HelpMarker( - HellionStrings.Settings_General_CustomSoundVolume_Description - + "\n\n" - + HellionStrings.Settings_Section_Sound_TabsHint - ); - } - } - - private void DrawLanguageSection(bool sectionJustEntered) - { - // Collapse every time the tab is freshly entered so state doesn't bleed across sessions. - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Language); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_SortAutoTranslate_Name, ref Mutable.SortAutoTranslate); - ImGuiUtil.HelpMarker(Language.Options_SortAutoTranslate_Description); - - ImGui.Spacing(); - - using ( - var combo = ImGuiUtil.BeginComboVertical( - Language.Options_Language_Name, - Mutable.LanguageOverride.Name() - ) - ) - { - if (combo.Success) - { - // None pinned first, then alphabetical by endonym so source order - // (append-only for serialisation safety) is not visible to users. - var sortedLanguages = Enum.GetValues() - .OrderBy(l => l == LanguageOverride.None ? 0 : 1) - .ThenBy(l => l.Name(), StringComparer.InvariantCulture); - foreach (var language in sortedLanguages) - { - if (ImGui.Selectable(language.Name())) - { - Mutable.LanguageOverride = language; - } - } - } - } - ImGuiUtil.HelpMarker( - string.Format(Language.Options_Language_Description, Plugin.PluginName) - ); - // v1.5.3: HellionChat's font stack covers 24 languages but FFXIV's - // engine only supports EN/DE/FR/JA for chat input/sending. - ImGuiUtil.WarningText(HellionStrings.Settings_Language_FFXIVCoverage_Warning); - ImGui.Spacing(); - - using ( - var combo = ImGuiUtil.BeginComboVertical( - Language.Options_CommandHelpSide_Name, - Mutable.CommandHelpSide.Name() - ) - ) - { - if (combo.Success) - { - foreach (var side in Enum.GetValues()) - { - if (ImGui.Selectable(side.Name(), Mutable.CommandHelpSide == side)) - { - Mutable.CommandHelpSide = side; - } - } - } - } - ImGuiUtil.HelpMarker( - string.Format(Language.Options_CommandHelpSide_Description, Plugin.PluginName) - ); - ImGui.Spacing(); - } - } - - private void DrawPerformanceSection(bool sectionJustEntered) - { - // Collapse every time the tab is freshly entered so state doesn't bleed across sessions. - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Performance); - if (!tree.Success) - return; - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.SetNextItemWidth(200f * ImGuiHelpers.GlobalScale); - if (ImGui.InputInt(Language.Options_MaxLinesToShow_Name, ref Mutable.MaxLinesToRender)) - { - Mutable.MaxLinesToRender = Math.Clamp(Mutable.MaxLinesToRender, 1, 10_000); - } - ImGuiUtil.HelpMarker(Language.Options_MaxLinesToShow_Description); - } - } -} diff --git a/HellionChat/Ui/SettingsTabs/ISettingsTab.cs b/HellionChat/Ui/SettingsTabs/ISettingsTab.cs deleted file mode 100755 index 9dbdd39..0000000 --- a/HellionChat/Ui/SettingsTabs/ISettingsTab.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace HellionChat.Ui.SettingsTabs; - -internal interface ISettingsTab -{ - string Name { get; } - void Draw(bool sectionJustEntered); -} diff --git a/HellionChat/Ui/SettingsTabs/Tabs.cs b/HellionChat/Ui/SettingsTabs/Tabs.cs deleted file mode 100755 index 15f2d8c..0000000 --- a/HellionChat/Ui/SettingsTabs/Tabs.cs +++ /dev/null @@ -1,601 +0,0 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Game.ClientState.Objects.SubKinds; -using Dalamud.Interface; -using Dalamud.Interface.Utility.Raii; -using FFXIVClientStructs.FFXIV.Client.UI; -using HellionChat.Code; -using HellionChat.Resources; -using HellionChat.Util; - -namespace HellionChat.Ui.SettingsTabs; - -internal sealed class Tabs : ISettingsTab -{ - private Plugin Plugin { get; } - private Configuration Mutable { get; } - - public string Name => HellionStrings.Settings_Tab_Tabs + "###tabs-tabs"; - - private int ToOpen = -2; - - internal Tabs(Plugin plugin, Configuration mutable) - { - Plugin = plugin; - Mutable = mutable; - } - - public void Draw(bool sectionJustEntered) - { - const string addTabPopup = "add-tab-popup"; - - ImGuiUtil.HelpText(HellionStrings.Tabs_Presets_Linkshell_Hint); - ImGui.Spacing(); - - if (ImGuiUtil.IconButton(FontAwesomeIcon.Plus, tooltip: Language.Options_Tabs_Add)) - ImGui.OpenPopup(addTabPopup); - - using (var popup = ImRaii.Popup(addTabPopup)) - { - if (popup) - { - if (ImGui.Selectable(Language.Options_Tabs_NewTab)) - Mutable.Tabs.Add(new Tab()); - - ImGui.Separator(); - - if ( - ImGui.Selectable( - string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_General) - ) - ) - Mutable.Tabs.Add(TabsUtil.VanillaGeneral); - - if ( - ImGui.Selectable( - string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_Event) - ) - ) - Mutable.Tabs.Add(TabsUtil.VanillaEvent); - - if ( - ImGui.Selectable( - string.Format(Language.Options_Tabs_Preset, Language.Tabs_Presets_Tell) - ) - ) - Mutable.Tabs.Add(TabsUtil.VanillaTellExclusive); - } - } - - var toRemove = -1; - var doOpens = ToOpen > -2; - for (var i = 0; i < Mutable.Tabs.Count; i++) - { - var tab = Mutable.Tabs[i]; - - // Sub-sections (Channels/Display/Notification/Input/Pop-out) are inlined into - // this loop body rather than extracted to helpers, because each one closes over - // the per-iteration `i` and `tab` state. Extraction would mean passing both - // into every helper without meaningful encapsulation gain. - - // ToOpen controls which tab-item TreeNode is open (e.g. after add/move). - // This is the outer level — not touched by sectionJustEntered. - if (doOpens) - ImGui.SetNextItemOpen(i == ToOpen); - - using var treeNode = ImRaii.TreeNode($"{tab.Name}###tab-{i}"); - if (!treeNode.Success) - continue; - - using var pushedId = ImRaii.PushId($"tab-{i}"); - - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.TrashAlt, - tooltip: Language.Options_Tabs_Delete - ) - ) - { - toRemove = i; - ToOpen = -1; - } - - ImGui.SameLine(); - - if ( - ImGuiUtil.IconButton(FontAwesomeIcon.ArrowUp, tooltip: Language.Options_Tabs_MoveUp) - && i > 0 - ) - { - (Mutable.Tabs[i - 1], Mutable.Tabs[i]) = (Mutable.Tabs[i], Mutable.Tabs[i - 1]); - ToOpen = i - 1; - } - - ImGui.SameLine(); - - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.ArrowDown, - tooltip: Language.Options_Tabs_MoveDown - ) - && i < Mutable.Tabs.Count - 1 - ) - { - (Mutable.Tabs[i + 1], Mutable.Tabs[i]) = (Mutable.Tabs[i], Mutable.Tabs[i + 1]); - ToOpen = i + 1; - } - - // Name and Icon are always visible — no sub-section collapse for these. - ImGui.InputText( - Language.Options_Tabs_Name, - ref tab.Name, - 512, - ImGuiInputTextFlags.EnterReturnsTrue - ); - - // Per-tab icon override added in v1.2.0. Falls back to default mapping if unset. - ImGui.TextUnformatted(HellionStrings.Tabs_Icon_Label); - ImGui.SameLine(); - ImGuiUtil.HelpMarker(HellionStrings.Tabs_Icon_HelpMarker); - - var iconCurrent = string.IsNullOrEmpty(tab.Icon) ? "" : tab.Icon; - var iconPreview = - iconCurrent.Length == 0 ? HellionStrings.Tabs_Icon_DefaultOption : iconCurrent; - using (var combo = ImRaii.Combo($"##icon-{i}", iconPreview)) - { - if (combo.Success) - { - // First option clears the icon and lets the default mapping take over. - if ( - ImGui.Selectable( - HellionStrings.Tabs_Icon_DefaultOption, - iconCurrent.Length == 0 - ) - ) - { - tab.Icon = null; - } - - ImGui.Separator(); - - // Options sourced from TabIconGlyphResolver.PickerOptions (single source of truth). - foreach (var option in TabIconGlyphResolver.PickerOptions) - { - var isSelected = string.Equals( - iconCurrent, - option, - StringComparison.OrdinalIgnoreCase - ); - if (ImGui.Selectable(option, isSelected)) - { - tab.Icon = option; - } - } - } - } - - ImGui.Spacing(); - - // ── Sub-section: Channels ───────────────────────────────────────── - // First because it answers "what does this tab collect?" — most important. - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using ( - var secChannels = ImRaii.TreeNode( - HellionStrings.Settings_Section_Tab_Channels + $"##sec-channels-{i}" - ) - ) - { - if (secChannels.Success) - { - using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); - ImGuiUtil.ChannelSelector(Language.Options_Tabs_Channels, tab.SelectedChannels); - ImGuiUtil.ExtraChatSelector( - Language.Options_Tabs_ExtraChatChannels, - ref tab.ExtraChatAll, - tab.ExtraChatChannels - ); - } - } - - ImGui.Spacing(); - - // ── Sub-section: Display ────────────────────────────────────────── - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using ( - var secDisplay = ImRaii.TreeNode( - HellionStrings.Settings_Section_Tab_Display + $"##sec-display-{i}" - ) - ) - { - if (secDisplay.Success) - { - using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); - - ImGui.Checkbox(Language.Options_Tabs_ShowTimestamps, ref tab.DisplayTimestamp); - - using ( - var combo = ImGuiUtil.BeginComboVertical( - Language.Options_Tabs_UnreadMode, - tab.UnreadMode.Name() - ) - ) - { - if (combo.Success) - { - foreach (var mode in Enum.GetValues()) - { - if (ImGui.Selectable(mode.Name(), tab.UnreadMode == mode)) - tab.UnreadMode = mode; - - if (mode.Tooltip() is { } tooltip && ImGui.IsItemHovered()) - ImGuiUtil.Tooltip(tooltip); - } - } - } - - // Only relevant when the global hide-when-inactive is on. - if (Mutable.HideWhenInactive) - ImGui.Checkbox( - Language.Options_Tabs_InactivityBehaviour, - ref tab.UnhideOnActivity - ); - } - } - - ImGui.Spacing(); - - // ── Sub-section: Notification ───────────────────────────────────── - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using ( - var secNotif = ImRaii.TreeNode( - HellionStrings.Settings_Section_Tab_Notification + $"##sec-notif-{i}" - ) - ) - { - if (secNotif.Success) - { - using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); - - ImGui.Checkbox( - HellionStrings.Tabs_NotificationSound_Enable_Name, - ref tab.EnableNotificationSound - ); - ImGuiUtil.HelpMarker(HellionStrings.Tabs_NotificationSound_Description); - if (tab.EnableNotificationSound) - { - using var notifIndent = ImRaii.PushIndent(10.0f); - // Build a readable preview label for the currently selected sound. - var soundPreview = - tab.NotificationSoundId <= 16 - ? $"{HellionStrings.Tabs_NotificationSound_Option} {tab.NotificationSoundId}" - : $"{HellionStrings.Tabs_NotificationSound_CustomOption} {tab.NotificationSoundId - 16}"; - using (var combo = ImRaii.Combo($"##notif-sound-{i}", soundPreview)) - { - if (combo.Success) - { - for (uint s = 1; s <= 16; s++) - { - if ( - ImGui.Selectable( - $"{HellionStrings.Tabs_NotificationSound_Option} {s}", - tab.NotificationSoundId == s - ) - ) - tab.NotificationSoundId = s; - } - - ImGui.Separator(); - - // Bundled custom sounds (ids 17-19). - for (uint n = 1; n <= 3; n++) - { - var customId = 16 + n; - if ( - ImGui.Selectable( - $"{HellionStrings.Tabs_NotificationSound_CustomOption} {n}", - tab.NotificationSoundId == customId - ) - ) - tab.NotificationSoundId = customId; - } - } - } - - // Let the user hear the currently selected sound without waiting - // for a real message to arrive in this tab. - ImGui.SameLine(); - if ( - ImGuiUtil.IconButton( - FontAwesomeIcon.Play, - tooltip: HellionStrings.Tabs_NotificationSound_Preview - ) - ) - { - var previewId = tab.NotificationSoundId; - if (previewId <= 16) - { - Plugin.Framework.RunOnFrameworkThread(() => - { - unsafe - { - UIGlobals.PlaySoundEffect(previewId); - } - }); - } - else - { - Plugin.CustomAudioPlayer.Play( - (int)previewId - 16, - Mutable.CustomSoundVolume - ); - } - } - } - - // Volume is stored as a 0-1 float but shown as 0-100%. - // Same field as General → Sound; shown here for convenience. - // DragFloatVertical derives its widget ID from the label text and exposes no - // override. We inline the equivalent (text label + SetNextItemWidth + DragFloat) - // to keep an explicit ##tab-volume-{i} ID, which reads more clearly than relying - // on the surrounding PushId("tab-{i}") scope to disambiguate identical labels. - // Volume is global (Mutable.CustomSoundVolume) and applies to every tab's - // notification sound, so it is shown unconditionally — not gated by the - // per-tab EnableNotificationSound toggle. - ImGui.TextUnformatted(HellionStrings.Settings_General_CustomSoundVolume_Name); - ImGui.SetNextItemWidth(-1); - var customSoundVolumePercent = Mutable.CustomSoundVolume * 100f; - if ( - ImGui.DragFloat( - $"##tab-volume-{i}", - ref customSoundVolumePercent, - 1f, - 0f, - 100f, - $"{customSoundVolumePercent:N0}%%", - ImGuiSliderFlags.AlwaysClamp - ) - ) - { - Mutable.CustomSoundVolume = customSoundVolumePercent / 100f; - } - // Applies globally — same value as in General → Sound. - ImGuiUtil.HelpMarker( - HellionStrings.Settings_General_CustomSoundVolume_Description - + "\n\n" - + HellionStrings.Settings_Section_Tab_Volume_AllTabsHint - ); - } - } - - ImGui.Spacing(); - - // ── Sub-section: Input ──────────────────────────────────────────── - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using ( - var secInput = ImRaii.TreeNode( - HellionStrings.Settings_Section_Tab_Input + $"##sec-input-{i}" - ) - ) - { - if (secInput.Success) - { - using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); - - ImGui.Checkbox(Language.Options_Tabs_NoInput, ref tab.InputDisabled); - if (!tab.InputDisabled) - { - var input = - tab.Channel?.ToChatType().Name() - ?? Language.Options_Tabs_NoInputChannel; - using ( - var combo = ImGuiUtil.BeginComboVertical( - Language.Options_Tabs_InputChannel, - input - ) - ) - { - if (combo.Success) - { - if ( - ImGui.Selectable( - Language.Options_Tabs_NoInputChannel, - tab.Channel == null - ) - ) - tab.Channel = null; - - foreach (var channel in Enum.GetValues()) - if ( - ImGui.Selectable( - channel.ToChatType().Name(), - tab.Channel == channel - ) - ) - tab.Channel = channel; - } - } - - var player = Plugin.ObjectTable.LocalPlayer; - if (tab.Channel == InputChannel.Tell && player != null) - { - ImGui.Checkbox( - Language.Options_Tabs_SenderMessages, - ref tab.AllSenderMessages - ); - ImGuiUtil.HelpText(Language.Options_Help_SenderMessages); - - var worlds = Sheets - .WorldsOnDatacenter(player) - .OrderByDescending(world => world.DataCenter.RowId) - .ThenBy(world => world.Name.ToString()) - .ToList(); - - using (ImRaii.ItemWidth(ImGui.GetWindowWidth() / 3f)) - { - ImGui.Text(Language.Options_Header_Target); - ImGui.SameLine(); - - var name = tab.TellTarget.Name; - if (ImGui.InputText("##targetInput", ref name, 21)) - tab.TellTarget.Name = name; - - ImGui.SameLine(); - - // Guard against an empty worlds list (character switch or sheet not yet populated) - // to avoid an out-of-bounds crash on worlds[selectedWorld]. - if (worlds.Count == 0) - { - ImGui.TextDisabled("(no worlds available)"); - } - else - { - var selectedWorld = worlds.FindIndex(world => - world.RowId == tab.TellTarget.World - ); - if (selectedWorld == -1) - selectedWorld = 0; - - using ( - var combo = ImRaii.Combo( - "###player-world", - worlds[selectedWorld].Name.ToString() - ) - ) - { - if (combo.Success) - { - var lastDc = worlds.First().DataCenter.RowId; - foreach (var (idx, world) in worlds.Index()) - { - if ( - ImGui.Selectable( - world.Name.ToString(), - selectedWorld == idx - ) - ) - { - selectedWorld = idx; - tab.TellTarget.World = worlds[ - selectedWorld - ].RowId; - } - - if (lastDc == world.DataCenter.RowId) - continue; - - lastDc = world.DataCenter.RowId; - ImGui.Separator(); - } - } - } - } - } - - var target = - (Plugin.TargetManager.SoftTarget ?? Plugin.TargetManager.Target) - as IPlayerCharacter; - using (ImRaii.Disabled(target == null)) - { - if (ImGui.Button("Set to target") && target != null) - tab.TellTarget.FromTarget(target); - } - } - } - } - } - - ImGui.Spacing(); - - // ── Sub-section: Pop-out window ─────────────────────────────────── - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using ( - var secPopOut = ImRaii.TreeNode( - HellionStrings.Settings_Section_Tab_PopOut + $"##sec-popout-{i}" - ) - ) - { - if (secPopOut.Success) - { - using var indent = ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false); - - ImGui.Checkbox(Language.Options_Tabs_PopOut, ref tab.PopOut); - if (tab.PopOut) - { - using var _ = ImRaii.PushIndent(10.0f); - ImGui.Checkbox( - Language.Options_Tabs_IndependentOpacity, - ref tab.IndependentOpacity - ); - if (tab.IndependentOpacity) - ImGuiUtil.DragFloatVertical( - Language.Options_Tabs_Opacity, - ref tab.Opacity, - 0.25f, - 0f, - 100f, - $"{tab.Opacity:N2}%%", - ImGuiSliderFlags.AlwaysClamp - ); - - ImGui.Checkbox( - Language.Options_Tabs_IndependentHide, - ref tab.IndependentHide - ); - if (tab.IndependentHide) - { - using var __ = ImRaii.PushIndent(10.0f); - ImGuiUtil.OptionCheckbox( - ref tab.HideDuringCutscenes, - Language.Options_HideDuringCutscenes_Name - ); - ImGui.Spacing(); - - ImGuiUtil.OptionCheckbox( - ref tab.HideWhenNotLoggedIn, - Language.Options_HideWhenNotLoggedIn_Name - ); - ImGui.Spacing(); - - ImGuiUtil.OptionCheckbox( - ref tab.HideWhenUiHidden, - Language.Options_HideWhenUiHidden_Name - ); - ImGui.Spacing(); - - ImGuiUtil.OptionCheckbox( - ref tab.HideInLoadingScreens, - Language.Options_HideInLoadingScreens_Name - ); - ImGui.Spacing(); - - ImGuiUtil.OptionCheckbox( - ref tab.HideInBattle, - Language.Options_HideInBattle_Name - ); - ImGui.Spacing(); - } - - ImGuiUtil.OptionCheckbox(ref tab.CanMove, Language.Popout_CanMove_Name); - ImGui.Spacing(); - - ImGuiUtil.OptionCheckbox(ref tab.CanResize, Language.Popout_CanResize_Name); - ImGui.Spacing(); - } - } - } - } - - if (toRemove > -1) - { - Mutable.Tabs.RemoveAt(toRemove); - Plugin.WantedTab = 0; - } - - if (doOpens) - ToOpen = -2; - } -} diff --git a/HellionChat/Ui/SettingsTabs/Window.cs b/HellionChat/Ui/SettingsTabs/Window.cs deleted file mode 100644 index 38e2e28..0000000 --- a/HellionChat/Ui/SettingsTabs/Window.cs +++ /dev/null @@ -1,198 +0,0 @@ -using Dalamud.Bindings.ImGui; -using Dalamud.Interface.Utility.Raii; -using HellionChat.Resources; -using HellionChat.Util; - -namespace HellionChat.Ui.SettingsTabs; - -internal sealed class Window : ISettingsTab -{ - private Plugin Plugin { get; } - private Configuration Mutable { get; } - - public string Name => HellionStrings.Settings_Tab_Window + "###tabs-window"; - - internal Window(Plugin plugin, Configuration mutable) - { - Plugin = plugin; - Mutable = mutable; - } - - public void Draw(bool sectionJustEntered) - { - DrawHideSection(sectionJustEntered); - ImGui.Spacing(); - DrawInactivityHideSection(sectionJustEntered); - ImGui.Spacing(); - DrawFrameSection(sectionJustEntered); - } - - private void DrawHideSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Hide); - if (!tree.Success) - { - return; - } - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_HideChat_Name, ref Mutable.HideChat); - ImGuiUtil.HelpMarker(Language.Options_HideChat_Description); - - ImGui.Checkbox( - Language.Options_HideDuringCutscenes_Name, - ref Mutable.HideDuringCutscenes - ); - ImGuiUtil.HelpMarker( - string.Format(Language.Options_HideDuringCutscenes_Description, Plugin.PluginName) - ); - - ImGui.Checkbox( - Language.Options_HideWhenNotLoggedIn_Name, - ref Mutable.HideWhenNotLoggedIn - ); - ImGuiUtil.HelpMarker( - string.Format(Language.Options_HideWhenNotLoggedIn_Description, Plugin.PluginName) - ); - - ImGui.Checkbox(Language.Options_HideWhenUiHidden_Name, ref Mutable.HideWhenUiHidden); - ImGuiUtil.HelpMarker( - string.Format(Language.Options_HideWhenUiHidden_Description, Plugin.PluginName) - ); - - ImGui.Checkbox( - Language.Options_HideInLoadingScreens_Name, - ref Mutable.HideInLoadingScreens - ); - ImGuiUtil.HelpMarker( - string.Format(Language.Options_HideInLoadingScreens_Description, Plugin.PluginName) - ); - - ImGui.Checkbox(Language.Options_HideInBattle_Name, ref Mutable.HideInBattle); - ImGuiUtil.HelpMarker(Language.Options_HideInBattle_Description); - - ImGui.Checkbox( - Language.Options_HideInNewGamePlusMenu_Name, - ref Mutable.HideInNewGamePlusMenu - ); - ImGuiUtil.HelpMarker(Language.Options_HideInNewGamePlusMenu_Description); - } - } - - private void DrawInactivityHideSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_InactivityHide); - if (!tree.Success) - { - return; - } - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_HideWhenInactive_Name, ref Mutable.HideWhenInactive); - ImGuiUtil.HelpMarker(Language.Options_HideWhenInactive_Description); - - if (!Mutable.HideWhenInactive) - { - return; - } - - ImGuiUtil.InputIntVertical( - Language.Options_InactivityHideTimeout_Name, - Language.Options_InactivityHideTimeout_Description, - ref Mutable.InactivityHideTimeout, - 1, - 10 - ); - // Floor at 2 seconds to prevent self-soft-lock. - Mutable.InactivityHideTimeout = Math.Max(2, Mutable.InactivityHideTimeout); - - using (ImRaii.Disabled(Mutable.HideInBattle)) - { - ImGui.Checkbox( - Language.Options_InactivityHideActiveDuringBattle_Name, - ref Mutable.InactivityHideActiveDuringBattle - ); - ImGuiUtil.HelpMarker(Language.Options_InactivityHideActiveDuringBattle_Description); - } - - using var channelTree = ImRaii.TreeNode(Language.Options_InactivityHideChannels_Name); - if (!channelTree.Success) - { - return; - } - - if ( - ImGuiUtil.CtrlShiftButton( - Language.Options_InactivityHideChannels_All_Label, - Language.Options_InactivityHideChannels_Button_Tooltip - ) - ) - { - Mutable.InactivityHideChannelsV2 = TabsUtil.AllChannels(); - Mutable.InactivityHideExtraChatAll = true; - Mutable.InactivityHideExtraChatChannels = []; - } - - ImGui.SameLine(); - if ( - ImGuiUtil.CtrlShiftButton( - Language.Options_InactivityHideChannels_None_Label, - Language.Options_InactivityHideChannels_Button_Tooltip - ) - ) - { - Mutable.InactivityHideChannelsV2 = []; - Mutable.InactivityHideExtraChatAll = false; - Mutable.InactivityHideExtraChatChannels = []; - } - - ImGui.Spacing(); - - ImGuiUtil.ChannelSelector( - Language.Options_Tabs_Channels, - Mutable.InactivityHideChannelsV2 - ); - ImGuiUtil.ExtraChatSelector( - Language.Options_Tabs_ExtraChatChannels, - ref Mutable.InactivityHideExtraChatAll, - Mutable.InactivityHideExtraChatChannels - ); - } - } - - private void DrawFrameSection(bool sectionJustEntered) - { - if (sectionJustEntered) - ImGui.SetNextItemOpen(false); - using var tree = ImRaii.TreeNode(HellionStrings.Settings_Section_Frame); - if (!tree.Success) - { - return; - } - - using (ImRaii.PushIndent(ImGui.GetStyle().IndentSpacing, false)) - { - ImGui.Checkbox(Language.Options_CanMove_Name, ref Mutable.CanMove); - ImGui.Checkbox(Language.Options_CanResize_Name, ref Mutable.CanResize); - - ImGui.Checkbox( - HellionStrings.Settings_Window_PopOutInputEnabled_Name, - ref Mutable.PopOutInputEnabled - ); - ImGuiUtil.HelpMarker(HellionStrings.Settings_Window_PopOutInputEnabled_Description); - - ImGui.Spacing(); - - // Fallback for off-screen windows after a display layout change. - if (ImGui.Button(HellionStrings.Settings_Window_ResetPosition_Name)) - Plugin.ChatLogWindow.RequestPositionReset = true; - ImGuiUtil.HelpMarker(HellionStrings.Settings_Window_ResetPosition_Description); - } - } -} diff --git a/HellionChat/Ui/StyleEngine/DrawListExtensions.cs b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs new file mode 100644 index 0000000..1d0545f --- /dev/null +++ b/HellionChat/Ui/StyleEngine/DrawListExtensions.cs @@ -0,0 +1,154 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.ManagedFontAtlas; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine; + +// Custom-drawing primitives for the v2.x style layer. Callers feed RGBA +// uints (typically resolved via TokenResolver) and these methods convert to +// ABGR before delegating to ImDrawList. Hover-sheen state lives in a small +// static dictionary keyed by constant strings — keep keys constant and +// scope to static UI elements so the per-key footprint stays bounded. +internal static class DrawListExtensions +{ + private const float SheenDurationSeconds = 0.65f; + private static readonly Dictionary SheenStarts = new(); + + public static void DrawHoverSheen( + this ImDrawListPtr dl, + Vector2 min, + Vector2 max, + uint accentRgba, + string elementId, + bool hovered + ) + { + if (!hovered) + { + // Reset so re-hover restarts the sweep instead of catching the + // tail end of a stale animation. + SheenStarts.Remove(elementId); + return; + } + + if (!SheenStarts.TryGetValue(elementId, out var started)) + { + started = DateTime.UtcNow; + SheenStarts[elementId] = started; + } + + var elapsed = (DateTime.UtcNow - started).TotalSeconds; + if (elapsed > SheenDurationSeconds) + return; + + var t = (float)(elapsed / SheenDurationSeconds); + var alpha = (byte)Math.Round(0x40 * (1f - t)); + var sheenAbgr = ((uint)alpha << 24) | 0x00FFFFFFu; + var sweepX = min.X + (max.X - min.X) * t; + dl.AddRectFilled( + new Vector2(sweepX - 12f, min.Y), + new Vector2(sweepX + 12f, max.Y), + sheenAbgr, + 2f + ); + + // Accent currently unused — reserved for a tinted-sweep variant that + // tracks the element's accent hue. Keeping it in the signature so + // call-sites don't churn when the tinted path lands. + _ = accentRgba; + } + + public static void DrawGlowBorder( + this ImDrawListPtr dl, + Vector2 min, + Vector2 max, + uint colorRgba, + float thickness = 1f, + int layers = 5 + ) + { + var r = (byte)((colorRgba >> 24) & 0xFFu); + var g = (byte)((colorRgba >> 16) & 0xFFu); + var b = (byte)((colorRgba >> 8) & 0xFFu); + var a = (byte)(colorRgba & 0xFFu); + for (var i = 0; i < layers; i++) + { + var fade = 1f - (i / (float)layers); + // Squared fade so outer layers vanish faster than a linear taper + // would suggest — keeps the glow halo from looking like a band. + var layerAlpha = (byte)Math.Round(a * fade * fade); + var layerAbgr = ((uint)layerAlpha << 24) | ((uint)b << 16) | ((uint)g << 8) | r; + var offset = i + 1; + dl.AddRect( + new Vector2(min.X - offset, min.Y - offset), + new Vector2(max.X + offset, max.Y + offset), + layerAbgr, + 3f, + ImDrawFlags.None, + thickness + ); + } + } + + public static unsafe void DrawSlipPolygon( + this ImDrawListPtr dl, + Vector2 min, + Vector2 max, + uint colorRgba, + float chamfer + ) + { + Span pts = stackalloc Vector2[6]; + BuildSlipPolygon(min, max, chamfer, pts); + var abgr = ColourUtil.RgbaToAbgr(colorRgba); + fixed (Vector2* p = pts) + dl.AddConvexPolyFilled(p, 6, abgr); + } + + // Geometry-only helper (no ImGui, no allocation) so the build suite can + // pin the slip-card shape without standing up an ImGui frame. + internal static void BuildSlipPolygon( + Vector2 min, + Vector2 max, + float chamfer, + Span pts + ) + { + var bound = Math.Min(max.X - min.X, max.Y - min.Y) * 0.5f; + var c = Math.Max(0f, Math.Min(chamfer, bound)); + // Clockwise wrap; bottom-left corner replaced by a 45-degree cut. + pts[0] = new Vector2(min.X + c, min.Y); + pts[1] = new Vector2(max.X, min.Y); + pts[2] = new Vector2(max.X, max.Y); + pts[3] = new Vector2(min.X + c, max.Y); + pts[4] = new Vector2(min.X, max.Y - c); + pts[5] = new Vector2(min.X, min.Y + c); + } + + public static void DrawHonorificHeader( + this ImDrawListPtr dl, + Vector2 origin, + string title, + Theme theme, + IFontHandle fontAwesomeFont, + float gap = 4f + ) + { + var crownAbgr = ColourUtil.RgbaToAbgr(theme.Colors.Identity); + var titleAbgr = ColourUtil.RgbaToAbgr(theme.Colors.TextPrimary); + var crownGlyph = FontAwesomeIcon.Crown.ToIconString(); + + // FontAwesome must wrap the crown specifically — the bracketed title + // renders in the regular text font, so the push has to be tight. + float crownWidth; + using (fontAwesomeFont.Push()) + { + crownWidth = ImGui.CalcTextSize(crownGlyph).X; + dl.AddText(origin, crownAbgr, crownGlyph); + } + dl.AddText(origin + new Vector2(crownWidth + gap, 0f), titleAbgr, $"«{title}»"); + } +} diff --git a/HellionChat/Ui/HellionStyle.cs b/HellionChat/Ui/StyleEngine/GlobalStyleScope.cs similarity index 61% rename from HellionChat/Ui/HellionStyle.cs rename to HellionChat/Ui/StyleEngine/GlobalStyleScope.cs index aa8f797..8820c59 100644 --- a/HellionChat/Ui/HellionStyle.cs +++ b/HellionChat/Ui/StyleEngine/GlobalStyleScope.cs @@ -3,72 +3,36 @@ using Dalamud.Interface.Utility.Raii; using HellionChat.Themes; using HellionChat.Util; -namespace HellionChat.Ui; +namespace HellionChat.Ui.StyleEngine; -// Theme-driven ImGui style override. PushGlobal is pushed once per frame -// in Plugin.Draw and drives every Hellion-rendered window. -internal static class HellionStyle +// Global theme style push, owned by the StyleEngine layer. Plugin.Draw +// wraps every WindowSystem.Draw call in this scope so all Hellion windows +// inherit the active theme's colours and layout. Crossfade reads through +// ThemeRegistry.TryGetActiveCrossfade to lerp the ABGR cache during the +// 300ms transition window without re-styling individual windows. +// +// Child surfaces draw over WindowBg, so the per-frame Window opacity +// modulates ChildBg's alpha down to zero once the user goes below full +// opacity — otherwise the theme alpha would double-multiply and the +// child read would look too solid. +internal static class GlobalStyleScope { - // Local color stack for the active theme. Use inside a - // `using var _ = HellionStyle.Push(theme);` block. - internal static IDisposable Push(Theme theme) - { - var a = theme.AbgrCache; - var stack = new StackHandle(); - stack.PushColorAbgr(ImGuiCol.Button, a.Primary); - stack.PushColorAbgr(ImGuiCol.ButtonHovered, a.PrimaryLight); - stack.PushColorAbgr(ImGuiCol.ButtonActive, a.PrimaryDark); - stack.PushColorAbgr(ImGuiCol.FrameBg, a.FrameBg); - stack.PushColorAbgr(ImGuiCol.FrameBgHovered, a.SurfaceHover); - stack.PushColorAbgr(ImGuiCol.FrameBgActive, a.Surface); - stack.PushColorAbgr(ImGuiCol.Border, a.Border); - stack.PushColorAbgr(ImGuiCol.Header, a.Surface); - stack.PushColorAbgr(ImGuiCol.HeaderHovered, a.SurfaceHover); - stack.PushColorAbgr(ImGuiCol.HeaderActive, a.Identity); - stack.PushColorAbgr(ImGuiCol.CheckMark, a.Primary); - stack.PushColorAbgr(ImGuiCol.SliderGrab, a.Primary); - stack.PushColorAbgr(ImGuiCol.SliderGrabActive, a.PrimaryLight); - return stack; - } - - // Global color and style stack pushed once per frame. - // windowOpacity: window background alpha (0.5-1.0). - internal static IDisposable PushGlobal( - Theme theme, - ThemeRegistry registry, - float windowOpacity = 1.0f - ) + public static IDisposable Push(Theme theme, ThemeRegistry registry, float windowOpacity) { var c = theme.Colors; var l = theme.Layout; - // Crossfade: PM-1 reads a lerped snapshot during the 300ms window - // following a Switch (TryGetActiveCrossfade returns false outside - // the window or while ReduceMotion is on). Only the ABGR-slot path - // crossfades -- WindowBg/ChildBg RGBA stays bound to the user's - // per-window opacity override and must not fade. See - // feedback_dalamud_pinning_override. ThemeAbgrCache a; if (!Plugin.Config.ReduceMotion && registry.TryGetActiveCrossfade(out var lerped)) - { a = lerped; - } else - { a = theme.AbgrCache; - } - - var stack = new StackHandle(); var alphaByte = (uint)Math.Clamp((int)(windowOpacity * 255f), 0x55, 0xFF); var windowBgWithAlpha = (c.WindowBg & 0xFFFFFF00u) | alphaByte; + var childBgWithAlpha = ResolveChildBgAlpha(c.ChildBg, windowOpacity); - // ChildBg alpha resolution lives in HellionStyleHelpers so the - // threshold logic can be covered by a pure-helper test in the - // build suite. - var childBgWithAlpha = HellionStyleHelpers.ResolveChildBgAlpha(c.ChildBg, windowOpacity); - - // Layout + var stack = new StackHandle(); stack.PushStyleVar(ImGuiStyleVar.WindowRounding, l.WindowRounding); stack.PushStyleVar(ImGuiStyleVar.ChildRounding, l.ChildRounding); stack.PushStyleVar(ImGuiStyleVar.PopupRounding, l.PopupRounding); @@ -79,58 +43,47 @@ internal static class HellionStyle stack.PushStyleVar(ImGuiStyleVar.WindowBorderSize, l.WindowBorderSize); stack.PushStyleVar(ImGuiStyleVar.FrameBorderSize, l.FrameBorderSize); - // Surfaces — WindowBg/ChildBg use opacity-modulated values (RGBA path); - // everything else reads from the pre-computed ABGR cache. stack.PushColor(ImGuiCol.WindowBg, windowBgWithAlpha); stack.PushColor(ImGuiCol.ChildBg, childBgWithAlpha); stack.PushColorAbgr(ImGuiCol.PopupBg, a.ChildBg); stack.PushColorAbgr(ImGuiCol.Border, a.Border); stack.PushColorAbgr(ImGuiCol.BorderShadow, 0u); - // Frames stack.PushColorAbgr(ImGuiCol.FrameBg, a.FrameBg); stack.PushColorAbgr(ImGuiCol.FrameBgHovered, a.SurfaceHover); stack.PushColorAbgr(ImGuiCol.FrameBgActive, a.Surface); - // Title bars stack.PushColorAbgr(ImGuiCol.TitleBg, a.WindowBg); stack.PushColorAbgr(ImGuiCol.TitleBgActive, a.Identity); stack.PushColorAbgr(ImGuiCol.TitleBgCollapsed, a.WindowBg); - // Buttons stack.PushColorAbgr(ImGuiCol.Button, a.Primary); stack.PushColorAbgr(ImGuiCol.ButtonHovered, a.PrimaryLight); stack.PushColorAbgr(ImGuiCol.ButtonActive, a.PrimaryDark); - // Headers / selectables stack.PushColorAbgr(ImGuiCol.Header, a.Surface); stack.PushColorAbgr(ImGuiCol.HeaderHovered, a.SurfaceHover); stack.PushColorAbgr(ImGuiCol.HeaderActive, a.Identity); - // Tabs stack.PushColorAbgr(ImGuiCol.Tab, a.FrameBg); stack.PushColorAbgr(ImGuiCol.TabHovered, a.PrimaryLight); stack.PushColorAbgr(ImGuiCol.TabActive, a.Identity); stack.PushColorAbgr(ImGuiCol.TabUnfocused, a.ChildBg); stack.PushColorAbgr(ImGuiCol.TabUnfocusedActive, a.PrimaryDark); - // Scrollbar stack.PushColorAbgr(ImGuiCol.ScrollbarBg, a.WindowBg); stack.PushColorAbgr(ImGuiCol.ScrollbarGrab, a.Surface); stack.PushColorAbgr(ImGuiCol.ScrollbarGrabHovered, a.AccentLight); stack.PushColorAbgr(ImGuiCol.ScrollbarGrabActive, a.Accent); - // Resize grip stack.PushColorAbgr(ImGuiCol.ResizeGrip, a.FrameBg); stack.PushColorAbgr(ImGuiCol.ResizeGripHovered, a.AccentLight); stack.PushColorAbgr(ImGuiCol.ResizeGripActive, a.Accent); - // Check mark + slider grab stack.PushColorAbgr(ImGuiCol.CheckMark, a.Primary); stack.PushColorAbgr(ImGuiCol.SliderGrab, a.Primary); stack.PushColorAbgr(ImGuiCol.SliderGrabActive, a.PrimaryLight); - // Separator stack.PushColorAbgr(ImGuiCol.Separator, a.Border); stack.PushColorAbgr(ImGuiCol.SeparatorHovered, a.PrimaryLight); stack.PushColorAbgr(ImGuiCol.SeparatorActive, a.Primary); @@ -138,6 +91,16 @@ internal static class HellionStyle return stack; } + // Child alpha is wiped to zero below full window opacity so WindowBg + // alone carries the coverage. 0.999f guards the user-facing 100% slider + // against float imprecision. + private static uint ResolveChildBgAlpha(uint themeChildBgRgba, float windowOpacity) + { + var alphaPreserved = windowOpacity >= 0.999f; + var childBgAlpha = alphaPreserved ? (themeChildBgRgba & 0xFFu) : 0u; + return (themeChildBgRgba & 0xFFFFFF00u) | childBgAlpha; + } + private sealed class StackHandle : IDisposable { private readonly List _items = new(64); diff --git a/HellionChat/Ui/StyleEngine/PushStack.cs b/HellionChat/Ui/StyleEngine/PushStack.cs new file mode 100644 index 0000000..9109505 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/PushStack.cs @@ -0,0 +1,71 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.ManagedFontAtlas; +using Dalamud.Interface.Utility.Raii; +using HellionChat.Themes; +using HellionChat.Util; + +namespace HellionChat.Ui.StyleEngine; + +// Token-aware bridge over Dalamud's ImRaii. Couples semantic Token lookups +// to the already-tracked push/pop machinery so callers express style intent +// instead of raw ImGuiCol slots. Counter-symmetry and exception-safety come +// from ImRaii, not from this class. +internal sealed class PushStack +{ + private readonly TokenResolver _resolver; + + public PushStack(TokenResolver resolver) + { + _resolver = resolver; + } + + public PushScope Begin() => new(_resolver); + + // Disposable scope handed to the using-block. Each Push.* call is a thin + // delegate to ImRaii / IFontHandle.Push with the token resolve in front; + // disposes in reverse order via the captured IDisposables. + internal sealed class PushScope : IDisposable + { + private readonly List _items = new(32); + private readonly TokenResolver _resolver; + + internal PushScope(TokenResolver resolver) + { + _resolver = resolver; + } + + public PushScope Color(Token token, Theme theme) + { + var slot = TokenMap.ToImGuiCol(token); + var rgba = _resolver.Resolve(token, theme.Colors); + _items.Add(ImRaii.PushColor(slot, ColourUtil.RgbaToAbgr(rgba))); + return this; + } + + public PushScope Style(ImGuiStyleVar var, float value) + { + _items.Add(ImRaii.PushStyle(var, value)); + return this; + } + + public PushScope Style(ImGuiStyleVar var, Vector2 value) + { + _items.Add(ImRaii.PushStyle(var, value)); + return this; + } + + public PushScope Font(IFontHandle font) + { + _items.Add(font.Push()); + return this; + } + + public void Dispose() + { + for (var i = _items.Count - 1; i >= 0; i--) + _items[i].Dispose(); + _items.Clear(); + } + } +} diff --git a/HellionChat/Ui/StyleEngine/TokenResolver.cs b/HellionChat/Ui/StyleEngine/TokenResolver.cs new file mode 100644 index 0000000..0491e56 --- /dev/null +++ b/HellionChat/Ui/StyleEngine/TokenResolver.cs @@ -0,0 +1,213 @@ +using Dalamud.Bindings.ImGui; +using HellionChat.Themes; + +namespace HellionChat.Ui.StyleEngine; + +// Semantic indirection between code and ThemeColors. ImGui-slot tokens map +// to ImGuiCol via TokenMap; custom-drawing tokens must be resolved directly +// and fed to DrawList; derived tokens lerp from base slots so user color +// picks propagate without persisting every variant. +public enum Token +{ + // ImGui-Slot tokens (24). + WindowBg, + ChildBg, + PopupBg, + FrameBg, + FrameBgHovered, + FrameBgActive, + Border, + BorderShadow, + Button, + ButtonHovered, + ButtonActive, + Header, + HeaderHovered, + HeaderActive, + Tab, + TabHovered, + TabActive, + Text, + TextDisabled, + CheckMark, + ScrollbarBg, + ScrollbarGrab, + ScrollbarGrabHovered, + ResizeGrip, + + // Custom-drawing tokens (11). + AccentPrimary, + AccentEmber, + SheenWhite, + GlowOuter, + HonorificCrown, + SlipFill, + SlipBorder, + StatusSuccess, + StatusDanger, + StatusWarning, + StatusInfo, + + // Derived surface/text tokens (6). + SurfaceBase, + SurfaceRaised, + SurfaceHover, + SurfaceActive, + TextMuted, + TextFaint, +} + +// Stateless lookup. Resolver values are RGBA (0xRRGGBBAA) — callers convert +// to ABGR at the ImGui boundary. +internal sealed class TokenResolver +{ + private const uint White = 0xFFFFFFFFu; + private const uint Black = 0x000000FFu; + private const uint Transparent = 0x00000000u; + + private static readonly Dictionary> Resolvers = new() + { + [Token.WindowBg] = c => c.WindowBg, + [Token.ChildBg] = c => c.ChildBg, + [Token.PopupBg] = c => Lerp(c.WindowBg, Black, 0.1f), + [Token.FrameBg] = c => c.FrameBg, + [Token.FrameBgHovered] = c => Lerp(c.FrameBg, c.Primary, 0.15f), + [Token.FrameBgActive] = c => Lerp(c.FrameBg, c.Primary, 0.3f), + [Token.Border] = c => c.Border, + [Token.BorderShadow] = c => Lerp(c.Border, Transparent, 0.5f), + [Token.Button] = c => Lerp(c.Primary, Transparent, 0.4f), + [Token.ButtonHovered] = c => c.PrimaryLight, + [Token.ButtonActive] = c => c.Primary, + [Token.Header] = c => Lerp(c.Primary, Transparent, 0.5f), + [Token.HeaderHovered] = c => c.PrimaryLight, + [Token.HeaderActive] = c => c.Primary, + [Token.Tab] = c => Lerp(c.Primary, Transparent, 0.7f), + [Token.TabHovered] = c => c.PrimaryLight, + [Token.TabActive] = c => c.Primary, + [Token.Text] = c => c.TextPrimary, + [Token.TextDisabled] = c => c.TextDim, + [Token.CheckMark] = c => c.Primary, + [Token.ScrollbarBg] = c => Lerp(c.WindowBg, Black, 0.2f), + [Token.ScrollbarGrab] = c => Lerp(c.Border, c.Primary, 0.3f), + [Token.ScrollbarGrabHovered] = c => Lerp(c.Border, c.Primary, 0.6f), + [Token.ResizeGrip] = c => Lerp(c.Border, c.Primary, 0.4f), + [Token.AccentPrimary] = c => c.Primary, + [Token.AccentEmber] = c => c.Accent, + [Token.SheenWhite] = _ => 0xFFFFFF20u, + [Token.GlowOuter] = c => Lerp(c.Primary, Transparent, 0.7f), + [Token.HonorificCrown] = c => c.Identity, + [Token.SlipFill] = c => Lerp(c.Surface, c.Primary, 0.05f), + [Token.SlipBorder] = c => c.Border, + [Token.StatusSuccess] = c => c.StatusSuccess, + [Token.StatusDanger] = c => c.StatusDanger, + [Token.StatusWarning] = c => c.StatusWarning, + [Token.StatusInfo] = c => c.StatusInfo, + + // Surface/text slots that already exist in ThemeColors read directly + // so user picks propagate; the rest lerp from base to keep slot count + // small. + [Token.SurfaceBase] = c => c.Surface, + [Token.SurfaceRaised] = c => Lerp(c.Surface, White, 0.06f), + [Token.SurfaceHover] = c => c.SurfaceHover, + [Token.SurfaceActive] = c => Lerp(c.Surface, c.Primary, 0.1f), + [Token.TextMuted] = c => c.TextMuted, + [Token.TextFaint] = c => c.TextDim, + }; + + public uint Resolve(Token token, ThemeColors theme) + { + if (!Resolvers.TryGetValue(token, out var fn)) + throw new InvalidOperationException( + $"Token {token} has no resolver entry. Add it to TokenResolver.Resolvers." + ); + return fn(theme); + } + + // Math.Round (ToEven) matches ThemeAbgrCacheLerp so derived tokens align + // with the crossfade path at midpoints. t is clamped before the math. + private static uint Lerp(uint from, uint to, float t) + { + t = Math.Clamp(t, 0f, 1f); + + var rf = (byte)((from >> 24) & 0xFFu); + var gf = (byte)((from >> 16) & 0xFFu); + var bf = (byte)((from >> 8) & 0xFFu); + var af = (byte)(from & 0xFFu); + + var rt = (byte)((to >> 24) & 0xFFu); + var gt = (byte)((to >> 16) & 0xFFu); + var bt = (byte)((to >> 8) & 0xFFu); + var at = (byte)(to & 0xFFu); + + var r = (byte)Math.Round(rf + (rt - rf) * t); + var g = (byte)Math.Round(gf + (gt - gf) * t); + var b = (byte)Math.Round(bf + (bt - bf) * t); + var a = (byte)Math.Round(af + (at - af) * t); + + return ((uint)r << 24) | ((uint)g << 16) | ((uint)b << 8) | a; + } +} + +// Every Token must be present so ToImGuiCol can distinguish "custom-drawing +// token" from "Token enum value added without a map entry". +internal static class TokenMap +{ + private static readonly Dictionary ImGuiSlot = new() + { + [Token.WindowBg] = ImGuiCol.WindowBg, + [Token.ChildBg] = ImGuiCol.ChildBg, + [Token.PopupBg] = ImGuiCol.PopupBg, + [Token.FrameBg] = ImGuiCol.FrameBg, + [Token.FrameBgHovered] = ImGuiCol.FrameBgHovered, + [Token.FrameBgActive] = ImGuiCol.FrameBgActive, + [Token.Border] = ImGuiCol.Border, + [Token.BorderShadow] = ImGuiCol.BorderShadow, + [Token.Button] = ImGuiCol.Button, + [Token.ButtonHovered] = ImGuiCol.ButtonHovered, + [Token.ButtonActive] = ImGuiCol.ButtonActive, + [Token.Header] = ImGuiCol.Header, + [Token.HeaderHovered] = ImGuiCol.HeaderHovered, + [Token.HeaderActive] = ImGuiCol.HeaderActive, + [Token.Tab] = ImGuiCol.Tab, + [Token.TabHovered] = ImGuiCol.TabHovered, + [Token.TabActive] = ImGuiCol.TabActive, + [Token.Text] = ImGuiCol.Text, + [Token.TextDisabled] = ImGuiCol.TextDisabled, + [Token.CheckMark] = ImGuiCol.CheckMark, + [Token.ScrollbarBg] = ImGuiCol.ScrollbarBg, + [Token.ScrollbarGrab] = ImGuiCol.ScrollbarGrab, + [Token.ScrollbarGrabHovered] = ImGuiCol.ScrollbarGrabHovered, + [Token.ResizeGrip] = ImGuiCol.ResizeGrip, + [Token.AccentPrimary] = null, + [Token.AccentEmber] = null, + [Token.SheenWhite] = null, + [Token.GlowOuter] = null, + [Token.HonorificCrown] = null, + [Token.SlipFill] = null, + [Token.SlipBorder] = null, + [Token.StatusSuccess] = null, + [Token.StatusDanger] = null, + [Token.StatusWarning] = null, + [Token.StatusInfo] = null, + [Token.SurfaceBase] = null, + [Token.SurfaceRaised] = null, + [Token.SurfaceHover] = null, + [Token.SurfaceActive] = null, + [Token.TextMuted] = null, + [Token.TextFaint] = null, + }; + + public static ImGuiCol ToImGuiCol(Token token) + { + if (!ImGuiSlot.TryGetValue(token, out var col)) + throw new InvalidOperationException( + $"Token {token} is missing from TokenMap. Add it as ImGuiCol or null." + ); + if (!col.HasValue) + throw new InvalidOperationException( + $"Token {token} is a custom-drawing token without an ImGuiCol mapping. " + + "Use TokenResolver.Resolve(token, theme) and feed the uint to DrawList." + ); + return col.Value; + } +} diff --git a/HellionChat/Ui/TabIconGlyphResolver.cs b/HellionChat/Ui/TabIconGlyphResolver.cs deleted file mode 100644 index 848c4c1..0000000 --- a/HellionChat/Ui/TabIconGlyphResolver.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace HellionChat.Ui; - -// Pure string resolver logic with no Dalamud dependency, kept in its own -// file so tests (HellionChat.Tests, no Dalamud reference) can call it directly. -// Used in the settings UI glyph picker and indirectly via TabIconMapping.Resolve. -internal static class TabIconGlyphResolver -{ - // Single source of truth for the glyph set; order matches the settings combobox. - public static readonly IReadOnlyList PickerOptions = - [ - "comment", - "comments", - "cog", - "users", - "user-friends", - "link", - "envelope", - "clock", - "hashtag", - "star", - "heart", - "bell", - "bookmark", - "flag", - "fire", - ]; - - // Derived from PickerOptions -- never maintain this manually. - private static readonly HashSet KnownGlyphs = new( - PickerOptions, - StringComparer.OrdinalIgnoreCase - ); - - // Tab.Name is localised, so we match against a pool of DE/EN synonyms. - private static readonly Dictionary NameDefaults = new( - StringComparer.OrdinalIgnoreCase - ) - { - ["allgemein"] = "comment", - ["general"] = "comment", - ["system"] = "cog", - ["free company"] = "users", - ["fc"] = "users", - ["gruppe"] = "user-friends", - ["group"] = "user-friends", - ["party"] = "user-friends", - ["linkshell"] = "link", - ["ls"] = "link", - ["cwls"] = "link", - ["tells"] = "envelope", - ["tell"] = "envelope", - }; - - // Resolves the glyph name for a tab. Priority order: - // 1. Tab.Icon override (if set): known glyph -> use it, unknown -> "hashtag" - // 2. Auto-tell tab -> autoTellGlyph if provided, else "clock" - // 3. Name default lookup - // 4. Fallback "hashtag" - public static string ResolveGlyphName(Tab tab, string? autoTellGlyph = null) - { - if (!string.IsNullOrWhiteSpace(tab.Icon)) - return KnownGlyphs.Contains(tab.Icon) ? tab.Icon : "hashtag"; - - if (tab.IsTempTab) - return autoTellGlyph ?? "clock"; - - if (tab.Name is { } name && NameDefaults.TryGetValue(name, out var byName)) - return byName; - - return "hashtag"; - } -} diff --git a/HellionChat/Ui/TabIconMapping.cs b/HellionChat/Ui/TabIconMapping.cs deleted file mode 100644 index a801e40..0000000 --- a/HellionChat/Ui/TabIconMapping.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Dalamud.Interface; - -namespace HellionChat.Ui; - -// Default icon mapping for tabs, used in top-tabs (icon prefix) and sidebar (icon-only with tooltip). -// Users can override per tab via Settings -> Tabs -> Tab.Icon. -// Pure string resolver logic lives in TabIconGlyphResolver (no Dalamud dependency) for testability. -internal static class TabIconMapping -{ - // Glyph name -> FontAwesomeIcon lookup for production resolve. - // Every key must also exist in TabIconGlyphResolver.PickerOptions. - // A missing key silently falls back to FontAwesomeIcon.Hashtag (degraded, no crash). - private static readonly Dictionary GlyphLookup = new( - StringComparer.OrdinalIgnoreCase - ) - { - ["comment"] = FontAwesomeIcon.Comment, - ["comments"] = FontAwesomeIcon.Comments, - ["cog"] = FontAwesomeIcon.Cog, - ["users"] = FontAwesomeIcon.Users, - ["user-friends"] = FontAwesomeIcon.UserFriends, - ["link"] = FontAwesomeIcon.Link, - ["envelope"] = FontAwesomeIcon.Envelope, - ["clock"] = FontAwesomeIcon.Clock, - ["hashtag"] = FontAwesomeIcon.Hashtag, - ["star"] = FontAwesomeIcon.Star, - ["heart"] = FontAwesomeIcon.Heart, - ["bell"] = FontAwesomeIcon.Bell, - ["bookmark"] = FontAwesomeIcon.Bookmark, - ["flag"] = FontAwesomeIcon.Flag, - ["fire"] = FontAwesomeIcon.Fire, - }; - - // Resolves the icon for a tab. Auto-tell tabs get a per-partner hashed icon - // from the tell pool so parallel tells differ by glyph shape, not just colour. - public static FontAwesomeIcon Resolve(Tab tab) - { - string? autoTellGlyph = null; - if (tab.IsTempTab && tab.TellTarget != null && tab.TellTarget.IsSet()) - autoTellGlyph = TabTintCache.GetIcon(tab); - - var glyph = TabIconGlyphResolver.ResolveGlyphName(tab, autoTellGlyph); - return GlyphLookup.TryGetValue(glyph, out var icon) ? icon : FontAwesomeIcon.Hashtag; - } -} diff --git a/HellionChat/Ui/TabTintCache.cs b/HellionChat/Ui/TabTintCache.cs deleted file mode 100644 index 5364ca4..0000000 --- a/HellionChat/Ui/TabTintCache.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace HellionChat.Ui; - -// Per-Tab cache wrapper around the pure AutoTellTabTint hash helpers. -// Each cache (tint, icon) carries its own name+world validation key so -// neither read path mutates the other's state — refilling one never -// invalidates the other. No string allocation in the steady-state lookup. -internal static class TabTintCache -{ - public static uint GetTint(Tab tab) - { - var name = tab.TellTarget.Name; - var world = tab.TellTarget.World; - if (tab._cachedTintTellName != name || tab._cachedTintTellWorld != world) - { - tab._cachedTintTellName = name; - tab._cachedTintTellWorld = world; - tab._cachedTellTint = AutoTellTabTint.For(name, world); - } - return tab._cachedTellTint; - } - - public static string GetIcon(Tab tab) - { - var name = tab.TellTarget.Name; - var world = tab.TellTarget.World; - if ( - tab._cachedTellIcon is null - || tab._cachedIconTellName != name - || tab._cachedIconTellWorld != world - ) - { - tab._cachedIconTellName = name; - tab._cachedIconTellWorld = world; - tab._cachedTellIcon = AutoTellTabTint.IconFor(name, world); - } - return tab._cachedTellIcon; - } -} diff --git a/HellionChat/Ui/Windows/ChannelPopoutPool.cs b/HellionChat/Ui/Windows/ChannelPopoutPool.cs new file mode 100644 index 0000000..4288348 --- /dev/null +++ b/HellionChat/Ui/Windows/ChannelPopoutPool.cs @@ -0,0 +1,75 @@ +using HellionChat.Util; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Windows; + +// Central orchestration: pre-allocates Config.MaxParallelPopouts pop-out +// windows via the injected factory, all registered once in the WindowSystem +// (PluginLifecycle.RegisterWindows, framework thread). Open/Close is IsOpen + +// Bind/Unbind only — NEVER runtime AddWindow/RemoveWindow (v1.4.9 Stage-2 +// freeze lesson). Pure DI-sink: no PayloadHandler in the ctor (plan §B.2). +internal sealed class ChannelPopoutPool +{ + private readonly List _instances; + private readonly PopoutSlotMap _slots; + private readonly ILogger _logger; + private readonly int _capacity; + + public ChannelPopoutPool( + Func windowFactory, + ILogger logger + ) + { + _logger = logger; + var capacity = Plugin.Config.MaxParallelPopouts; + _capacity = capacity; + _instances = new List(capacity); + for (var i = 0; i < capacity; i++) + _instances.Add(windowFactory(i)); + _slots = new PopoutSlotMap(capacity); + + // Route each window's in-body close through the pool so closing releases + // the slot. Wired here (post-construction) rather than via ctor to avoid + // a Window->Pool edge that would re-enter pool resolution (plan §B.2). + foreach (var window in _instances) + window.CloseRequested = TryClose; + } + + // Iterated once by PluginLifecycle.RegisterWindows (framework thread) and + // by ChannelPopoutInitHostedService (PayloadHandler setter). + public IReadOnlyList Instances => _instances; + + public bool TryOpen(Tab tab) + { + // A popped tab gets its own input bar, so strip stale tell state first — + // otherwise a popped-out stale-tell tab would be a send surface that + // bypasses the click-path activation strip. Previous = the main window's + // active tab; popping the active tab itself must not strip (TR-4 guard). + TabLifecycleHelpers.OnTabActivated(tab, Plugin.Instance.MainWindow?.ActiveTab); + + var slot = _slots.TryReserve(tab.Identifier); + if (slot < 0) + { + _logger.LogWarning( + "Channel popout pool is full ({Capacity} slots); ignoring open for {Name}.", + _capacity, + tab.Name + ); + return false; + } + + _instances[slot].Bind(tab); + return true; + } + + public void TryClose(Guid id) + { + var slot = _slots.Release(id); + if (slot < 0) + return; // idempotent: unknown/unbound id is a silent no-op + + _instances[slot].Unbind(); + } + + public bool IsOpen(Guid id) => _slots.IsActive(id); +} diff --git a/HellionChat/Ui/Windows/ChannelPopoutWindow.cs b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs new file mode 100644 index 0000000..606f82c --- /dev/null +++ b/HellionChat/Ui/Windows/ChannelPopoutWindow.cs @@ -0,0 +1,137 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using HellionChat.Ui.Components; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Windows; + +// One pre-allocated pop-out window bound to a single Tab. Pure DI-sink: the +// PayloadHandler arrives via AttachPayloadHandler (post-build setter), NEVER +// via ctor — see plan §B.2. The ###id carries the slot index so all N +// instances are unique for WindowSystem.AddWindow and ImGui state is stable +// per slot (not per bound tab). +internal sealed class ChannelPopoutWindow : Window +{ + private readonly int _slotIndex; + private readonly MessageList _messages; + private readonly InputBar _input; + private readonly ILogger _logger; + private readonly FontManager _fonts; + + public ChannelPopoutWindow( + int slotIndex, + MessageList messages, + InputBar input, + ILogger logger, + FontManager fonts + ) + : base($"{Plugin.PluginName}###hellion_popout_{slotIndex}") + { + _slotIndex = slotIndex; + _messages = messages; + _input = input; + _logger = logger; + _fonts = fonts; + IsOpen = false; + RespectCloseHotkey = false; + ShowCloseButton = false; + } + + public int SlotIndex => _slotIndex; + + public Tab? Bound { get; private set; } + + // Wired post-build by ChannelPopoutPool so closing routes through the pool + // (which owns the slot map). The window can't reach the pool by ctor without + // a DI cycle, so the pool sets this after construction. See plan §B.2. + public Action? CloseRequested { get; set; } + + // Post-build setter — see plan §B.2. Wired by ChannelPopoutInitHostedService. + public void AttachPayloadHandler(PayloadHandler handler) => + _messages.AttachPayloadHandler(handler); + + public void Bind(Tab tab) + { + Bound = tab; + + var isTell = tab is { IsTempTab: true, TellTarget: { } target } && target.IsSet(); + // Master §4.3 default sizes: Tell is the more compact conversation window. + Size = isTell ? new Vector2(380f, 320f) : new Vector2(420f, 320f); + SizeCondition = ImGuiCond.FirstUseEver; + + // Visible label tracks the bound tab; the ###id stays slot-stable so + // ImGui keeps this slot's position/size across binds. + WindowName = $"{tab.Name}###hellion_popout_{_slotIndex}"; + + IsOpen = true; + } + + public void Unbind() + { + Bound = null; + IsOpen = false; + } + + public override void PreDraw() + { + // Gate the native title bar on the user toggle (1.5.6 parity). DrawHeader + // carries the close button in-body regardless, so hiding the title bar + // never strands the pop-out. Reset from a fresh base each frame so + // toggling the bar back on clears NoTitleBar. + Flags = Plugin.Config.ShowPopOutTitleBar + ? ImGuiWindowFlags.None + : ImGuiWindowFlags.NoTitleBar; + } + + public override void Draw() + { + if (Bound is null) + return; + + DrawHeader(Bound); + + // The header close button can unbind us mid-frame (CloseRequested -> + // pool.TryClose -> Unbind nulls Bound). Re-check before the body so we + // never hand a null tab to MessageList/InputBar in this same Draw call. + if (Bound is null) + return; + + var inputHeight = InputBar.Height; + using ( + var body = ImRaii.Child( + $"##hellion-popout-body-{_slotIndex}", + new Vector2(-1f, -inputHeight) + ) + ) + { + if (body.Success) + _messages.Draw(Bound); + } + + _input.Draw(Bound); + } + + private void DrawHeader(Tab tab) + { + // Identifier + close action. Pop-In/Pin are wired in the same row; the + // close button is the canonical "send the tab back" affordance for v1.8.0. + // PartnerHonorific is deferred (HonorificService has no per-target title, + // plan §D / Sub-Spec WARN-8) — no honorific row here. + ImGui.TextUnformatted(tab.Name); + ImGui.SameLine(); + using (_fonts.FontAwesome.Push()) + { + ImGui.SameLine(ImGui.GetContentRegionAvail().X - ImGui.GetFrameHeight()); + if (ImGui.Button($"{FontAwesomeIcon.Times.ToIconString()}##popin-{_slotIndex}")) + { + // Pop-In: release the slot via the pool (not a bare Unbind, which + // would orphan the slot — the pool owns the slot bookkeeping). + CloseRequested?.Invoke(tab.Identifier); + } + } + ImGui.Separator(); + } +} diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs new file mode 100644 index 0000000..524d3ad --- /dev/null +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -0,0 +1,358 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Utility; +using Dalamud.Interface.Utility.Raii; +using Dalamud.Interface.Windowing; +using HellionChat.Util; + +namespace HellionChat.Ui.Windows; + +// Top-level chat window assembled from the components layer. Layout from +// top to bottom: honorific header, horizontal body with sidebar + main +// area (messages + input bar), and the status strip pinned to the +// bottom. The window-level theme push stays on the global plugin draw +// path for now — this window only composes content. +// +// Components are fully qualified through the Ui.Components prefix so the +// old Ui.StatusBar type (still alive until the cleanup block removes it) +// cannot shadow the new layer through parent-namespace resolution. +internal sealed class MainWindow : Window +{ + private const float DefaultWidth = 620f; + private const float DefaultHeight = 340f; + private const float MinWidth = 480f; + private const float MinHeight = 260f; + + private readonly Components.HonorificHeader _honorific; + private readonly Components.Sidebar _sidebar; + private readonly Components.TopTabBar _topTabs; + private readonly Components.MessageList _messages; + private readonly Components.InputBar _input; + private readonly Components.StatusBar _status; + private readonly Lender _handlerLender; + + private Tab? _activeTab; + + // Runtime-only hide: window stays IsOpen but DrawConditions skips it, so the + // chat-activation key can restore it (1.5.6 HideState.User parity). + private bool _userHidden; + + public Vector2 LastWindowPos { get; private set; } = Vector2.Zero; + public Vector2 LastWindowSize { get; private set; } = Vector2.Zero; + internal unsafe ImGuiViewport* LastViewport; + + // 1.5.6 viewport-guard input: tracked in Draw, read by PreDraw next frame. + private bool _wasDocked; + + public MainWindow( + Components.HonorificHeader honorific, + Components.Sidebar sidebar, + Components.TopTabBar topTabs, + Components.MessageList messages, + Components.InputBar input, + Components.StatusBar status, + Lender handlerLender + ) + : base($"{Plugin.PluginName}###hellion-main") + { + _honorific = honorific; + _sidebar = sidebar; + _topTabs = topTabs; + _messages = messages; + _input = input; + _status = status; + _handlerLender = handlerLender; + + Size = new Vector2(DefaultWidth, DefaultHeight); + SizeCondition = ImGuiCond.FirstUseEver; + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(MinWidth, MinHeight), + MaximumSize = new Vector2(float.MaxValue, float.MaxValue), + }; + // 1.5.6 parity: the chat always shows on login. The window stays closeable + // and hideable within a session, but that state is not carried across starts. + IsOpen = true; + RespectCloseHotkey = false; + } + + // UI-12: per-window focus-dependent opacity. ResolveBgAlpha stays guard-free + // and pure so the self-test can drive it directly; PreDraw owns the guard + + // wiring. 1.5.6 parity (focused → WindowOpacity, unfocused → + // WindowOpacityInactive, ChatLogWindow.PreOpenCheck 1d3b429:724). + internal float ResolveBgAlpha(bool isFocused) => + isFocused ? Plugin.Config.WindowOpacity : Plugin.Config.WindowOpacityInactive; + + // B1-2 / P7: rebuild flags from a fresh base every frame so toggling + // CanMove/CanResize/ShowTitleBar back on actually CLEARS NoMove/NoResize/ + // NoTitleBar (not accumulating). Move/resize/title-bar logic as 1.5.6 + // (ChatLogWindow.PreOpenCheck 1d3b429:703-710); base flags = today's + // MainWindow set (NoScrollbar|NoScrollWithMouse — the message list owns its + // own scroll; 1.5.6's NoFocusOnAppearing is deliberately not restored). + internal static ImGuiWindowFlags ResolveFlags(bool canMove, bool canResize, bool showTitleBar) + { + var flags = ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse; + if (!canMove) + flags |= ImGuiWindowFlags.NoMove; + if (!canResize) + flags |= ImGuiWindowFlags.NoResize; + if (!showTitleBar) + flags |= ImGuiWindowFlags.NoTitleBar; + return flags; + } + + public override void PreDraw() + { + // Dalamud's WindowHost turns Window.BgAlpha into SetNextWindowBgAlpha + // (WindowHost.cs:650-652), which REPLACES this one window's WindowBg + // alpha (imgui.cpp:7229). The global GlobalStyleScope clamp is left + // untouched, so Settings/DbViewer/popouts/wizard keep today's opacity. + // Viewport guard (1.5.6 parity, ChatLogWindow.PreOpenCheck 1d3b429:718): + // only drive BgAlpha while the window is on the main viewport and not + // docked. On a floated own-viewport (Dalamud multi-viewport mode) the + // WindowBg alpha would compose against the OS-layer alpha (double + // transparency), so leave BgAlpha null there and let the global scope + // govern. LastViewport/_wasDocked are last frame's values from Draw + // (one-frame latency, accepted, matches 1.5.6). + unsafe + { + if (LastViewport == ImGuiHelpers.MainViewport.Handle && !_wasDocked) + BgAlpha = ResolveBgAlpha(IsFocused); + else + BgAlpha = null; + } + + Flags = ResolveFlags( + Plugin.Config.CanMove, + Plugin.Config.CanResize, + Plugin.Config.ShowTitleBar + ); + } + + public Tab? ActiveTab => _activeTab; + + // Re-anchors the active-tab reference when the tab it points at is removed + // (eviction / logout). Reference compare, so it is immune to the SaveConfig + // temp-tab strip window where a tab is briefly absent from Config.Tabs; the + // re-seeded tab runs through OnTabActivated so a programmatic switch strips + // stale tell state the way a click would. + internal void ResetActiveTabIfRemoved(Tab removed) + { + if (!ReferenceEquals(_activeTab, removed)) + return; + + var next = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null; + _activeTab = next; + if (next is not null) + TabLifecycleHelpers.OnTabActivated(next, removed); + } + + // Programmatic tab activation for the header quick-picker. Mirrors the click + // path in TopTabBar/Sidebar exactly (previous → set → OnTabActivated) so a + // header pick strips tell-state and resets unread the way a real click does. + internal void ActivateTab(Tab tab) + { + if (ReferenceEquals(_activeTab, tab)) + return; + + var previous = _activeTab; + _activeTab = tab; + TabLifecycleHelpers.OnTabActivated(tab, previous); + } + + // Tab-cycle entry point for the ChatTabForward/Backward keybinds. Empty list is a + // no-op; a null active tab seeds the index to 0; a single-tab cycle that lands on + // the already-active tab is a no-op (ActivateTab early-returns on the same reference). + // Routes through ActivateTab so the cycle strips stale tell state + re-derives the + // channel exactly like a sidebar/top-tab click. Pop-out focus-forward stays + // deferred (no focus contract) — main-window tabs only. + internal void ChangeTabDelta(int delta) + { + var tabs = Plugin.Config.Tabs; + if (tabs.Count == 0) + return; + + var idx = _activeTab is null ? 0 : tabs.IndexOf(_activeTab); + if (idx < 0) + idx = 0; // active tab not in the list (mid-strip) -> start from the first + + ActivateTab(tabs[TabLifecycleHelpers.WrapTabIndex(idx, delta, tabs.Count)]); + } + + // Internal accessors for self-tests so the probes can reach the live + // component without exposing them as public surface. + internal Components.Sidebar GetSidebarForSelfTest() => _sidebar; + + internal Components.HonorificHeader GetHonorificHeaderForSelfTest() => _honorific; + + internal Components.MessageList GetMessageListForSelfTest() => _messages; + + public override bool DrawConditions() => !_userHidden; + + internal void UserHide() => _userHidden = true; + + // Chat-activation keybind (Enter) entry point. Field writes only, so it is safe + // from the framework thread; the draw path applies focus next frame. + internal void ActivateChat() + { + _userHidden = false; + if (!IsOpen) + { + IsOpen = true; + Plugin.Config.MainWindowOpen = true; + } + BringToFront(); + _input.Activate = true; + } + + // new-shadow on Window.Toggle so the open path also writes Config. A user-hide + // counts as "not visible", so /hellion is a reliable one-press recovery even when + // the Enter keybind can't fire (DirectChat / a focused game text field). + public new void Toggle() + { + var visible = IsOpen && !_userHidden; + IsOpen = !visible; + if (IsOpen) + _userHidden = false; + Plugin.Config.MainWindowOpen = IsOpen; + } + + public override void OnClose() + { + Plugin.Config.MainWindowOpen = false; + } + + public override void Draw() + { + LastWindowPos = ImGui.GetWindowPos(); + LastWindowSize = ImGui.GetWindowSize(); + unsafe + { + LastViewport = ImGui.GetWindowViewport().Handle; + } + _wasDocked = ImGui.IsWindowDocked(); + + // Primary pool-reset path; InputPreview has a defensive fallback for the MainWindow-closed edge case. + _handlerLender.ResetCounter(); + + // First-frame seed: the active tab defaults to the first persisted + // tab so the message list isn't empty on a clean session. + if (_activeTab is null && Plugin.Config.Tabs.Count > 0) + { + var seeded = Plugin.Config.Tabs[0]; + _activeTab = seeded; + // The seeded Tabs[0] is the likeliest legacy stale-tell carrier + // (pre-coupling the detour wrote here); strip it like any activation. + TabLifecycleHelpers.OnTabActivated(seeded, null); + } + else if (_activeTab is { } active && !Plugin.Config.Tabs.Contains(active)) + { + // Active tab is no longer in the list (e.g. a wholesale config import + // the service repair paths never see). Re-seed on the Draw thread. The + // Contains read shares the pre-existing unsynchronized-Tabs-list + // exposure that spec §6 defers (SaveConfig also strips from the worker + // thread); this adds one more racing read, not a new hazard class. + var reseed = Plugin.Config.Tabs.Count > 0 ? Plugin.Config.Tabs[0] : null; + _activeTab = reseed; + if (reseed is not null) + TabLifecycleHelpers.OnTabActivated(reseed, active); + } + + // The active tab's messages are on screen, so it carries no unread badge + // (1.5.6 convention: zero the current tab every frame so the dot only ever + // shows on tabs you are NOT looking at). + if (_activeTab is { } seenTab) + seenTab.Unread = 0; + + var statusHeight = Components.StatusBar.Height; + + using (var body = ImRaii.Child("##hellion-body", new Vector2(-1f, -statusHeight))) + { + if (body.Success) + DrawBody(); + } + + _status.Draw(_activeTab); + } + + private void DrawBody() + { + var bodyWidth = ImGui.GetContentRegionAvail().X; + _honorific.Draw(bodyWidth); + + if (Plugin.Config.MainWindowLayoutMode == MainWindowLayoutMode.TopTabs) + { + _topTabs.Draw(Plugin.Config.Tabs, ref _activeTab); + using (ImRaii.Group()) + { + DrawMainArea(); + } + return; + } + + // Sidebar layout (default). + using (ImRaii.Group()) + { + _sidebar.Draw(bodyWidth, Plugin.Config.Tabs, ref _activeTab); + } + + ImGui.SameLine(); + + using (ImRaii.Group()) + { + DrawMainArea(); + } + } + + private void DrawMainArea() + { + var inputHeight = Components.InputBar.Height; + + // Shrink the message child when Inside-mode preview is active so the + // inline preview block does not overlap the message list. PreviewHeight + // lags one frame behind on the very first keystroke (same as v1.5.6). + var previewHeight = + Plugin.Config.PreviewPosition is PreviewPosition.Inside + && Plugin.InputPreview.IsDrawable + ? Plugin.InputPreview.PreviewHeight + : 0f; + + using ( + var messages = ImRaii.Child( + "##hellion-main-area", + new Vector2(-1f, -(inputHeight + previewHeight)) + ) + ) + { + if (messages.Success) + _messages.Draw(_activeTab!); + } + + // Inside-mode inline render: measure first so PreviewHeight is fresh + // for the next frame's reservation, then draw between messages and input. + if ( + Plugin.Config.PreviewPosition is PreviewPosition.Inside + && Plugin.InputPreview.IsDrawable + ) + { + Plugin.InputPreview.CalculatePreviewHeight(); + Plugin.InputPreview.DrawPreview(); + } + + _input.Draw(_activeTab); + + // Tooltip-mode: sampled hover-state from InputBar reflects the actual + // InputText widget (after-Draw IsItemHovered would target a QuickButton). + // ImRaii.Tooltip has no Success guard — BeginTooltip always runs in ctor. + if ( + Plugin.Config.PreviewPosition is PreviewPosition.Tooltip + && Plugin.InputPreview.IsDrawable + && _input.WasInputTextHovered + ) + { + ImGui.SetNextWindowSize(new Vector2(500 * ImGuiHelpers.GlobalScale, -1)); + using var tooltip = ImRaii.Tooltip(); + Plugin.InputPreview.DrawPreview(); + } + } +} diff --git a/HellionChat/Ui/Windows/PopoutSlotMap.cs b/HellionChat/Ui/Windows/PopoutSlotMap.cs new file mode 100644 index 0000000..c1ff106 --- /dev/null +++ b/HellionChat/Ui/Windows/PopoutSlotMap.cs @@ -0,0 +1,55 @@ +namespace HellionChat.Ui.Windows; + +// Pure slot bookkeeping for the channel-popout pool: maps a tab's session +// identifier (Guid) to a fixed slot index. Deliberately Dalamud-free so the +// Build-Suite can unit-test reserve/release/capacity in isolation +// (Dalamud-coupled classes cannot be instantiated in the xUnit AppDomain). +internal sealed class PopoutSlotMap +{ + private readonly int _capacity; + private readonly Dictionary _active = new(); + private readonly bool[] _slotUsed; + + public PopoutSlotMap(int capacity) + { + _capacity = capacity < 0 ? 0 : capacity; + _slotUsed = new bool[_capacity]; + } + + public int Count => _active.Count; + + public bool IsActive(Guid id) => _active.ContainsKey(id); + + // Reserves the lowest free slot for id and returns its index. If id is + // already bound, returns its existing slot (idempotent re-open). Returns + // -1 when the pool is full. + public int TryReserve(Guid id) + { + if (_active.TryGetValue(id, out var existing)) + return existing; + + for (var i = 0; i < _capacity; i++) + { + if (!_slotUsed[i]) + { + _slotUsed[i] = true; + _active[id] = i; + return i; + } + } + + return -1; + } + + // Releases id's slot and returns its index, or -1 if id was not bound + // (idempotent no-op for unknown ids). + public int Release(Guid id) + { + if (!_active.TryGetValue(id, out var slot)) + return -1; + + _active.Remove(id); + _slotUsed[slot] = false; + return slot; + } +} diff --git a/HellionChat/Ui/Windows/SettingsWindow.cs b/HellionChat/Ui/Windows/SettingsWindow.cs new file mode 100644 index 0000000..b7b8537 --- /dev/null +++ b/HellionChat/Ui/Windows/SettingsWindow.cs @@ -0,0 +1,119 @@ +using System.Numerics; +using Dalamud.Bindings.ImGui; +using Dalamud.Interface.Windowing; +using Dalamud.Utility; +using HellionChat.Resources; +using HellionChat.Ui.Components.Settings; +using HellionChat.Ui.Components.Settings.Tabs; +using Microsoft.Extensions.Logging; + +namespace HellionChat.Ui.Windows; + +// `internal` to match the Plugin.SettingsWindow property in W2; `public` here +// would raise CS0053 against the internal members. Matches MainWindow shape. +internal sealed class SettingsWindow : Window +{ + private readonly Plugin _plugin; + private readonly TabSidebar _sidebar; + private readonly ContentArea _content; + private readonly ThemePicker _themePicker; + private readonly ColorPicker _colorPicker; + private readonly LivePreviewPanel _livePreview; + private readonly AppearanceTab _appearance; + private readonly GeneralTab _general; + private readonly ChatTab _chat; + private readonly WindowTab _window; + private readonly ChannelsTab _channels; + private readonly DataPrivacyTab _dataPrivacy; + private readonly AboutTab _about; + + public SettingsWindow( + Plugin plugin, + TabSidebar sidebar, + ContentArea content, + ThemePicker themePicker, + ColorPicker colorPicker, + LivePreviewPanel livePreview, + AppearanceTab appearance, + GeneralTab general, + ChatTab chat, + WindowTab window, + ChannelsTab channels, + DataPrivacyTab dataPrivacy, + AboutTab about, + ILoggerFactory loggerFactory + ) + : base($"{Language.Settings_Title.Format(Plugin.PluginName)}###chat2-settings") + { + _plugin = plugin; + _sidebar = sidebar; + _content = content; + _themePicker = themePicker; + _colorPicker = colorPicker; + _livePreview = livePreview; + _appearance = appearance; + _general = general; + _chat = chat; + _window = window; + _channels = channels; + _dataPrivacy = dataPrivacy; + _about = about; + _ = loggerFactory; + + Size = new Vector2(720, 540); + SizeCondition = ImGuiCond.FirstUseEver; + SizeConstraints = new WindowSizeConstraints + { + MinimumSize = new Vector2(600, 400), + MaximumSize = new Vector2(float.MaxValue, float.MaxValue), + }; + Flags = ImGuiWindowFlags.NoCollapse; + + // Carry-over from v1.6.0 stub: Escape must not auto-close, and toggle + // events must not play default Dalamud window sounds. + RespectCloseHotkey = false; + DisableWindowSounds = true; + } + + public override void Draw() + { + _sidebar.Draw(); + ImGui.SameLine(); + _content.Draw(_sidebar.ActiveTab, RenderActiveTab); + } + + private void RenderActiveTab(string tabId) + { + switch (tabId) + { + case "general": + _general.Draw(); + break; + case "chat": + _chat.Draw(); + break; + case "window": + _window.Draw(); + break; + case "channels": + _channels.Draw(); + break; + case "data-privacy": + _dataPrivacy.Draw(); + break; + case "about": + _about.Draw(); + break; + case "appearance": + _appearance.Draw(); + break; + default: + ImGui.TextUnformatted($"[{tabId}] tab content lands in later task"); + break; + } + } + + // AboutTab is owned here (not MainWindow) and rendered only via the private + // RenderActiveTab; this exposes it for the integrations-status SelfTest. + internal AboutTab GetAboutTabForSelfTest() => _about; +} diff --git a/HellionChat/Util/ColourUtil.cs b/HellionChat/Util/ColourUtil.cs index 31b08c4..5ece96b 100755 --- a/HellionChat/Util/ColourUtil.cs +++ b/HellionChat/Util/ColourUtil.cs @@ -30,6 +30,30 @@ internal static class ColourUtil ); } + internal static Vector4 RgbaToVector4(uint rgba) + { + var (r, g, b) = RgbaToRgbComponents(rgba); + var a = (byte)(rgba & 0xFFu); + return new Vector4(r / 255f, g / 255f, b / 255f, a / 255f); + } + + internal static uint Vector4ToRgba(Vector4 col) + { + // Clamp guards against future ImGuiColorEditFlags.HDR feeding out-of-range + // components: a raw byte-cast would wrap (e.g. (byte)Math.Round(2.0f*255)=254). + // Mirrors the ApplyAlpha clamping pattern in this file. + var r = Math.Clamp(col.X, 0f, 1f); + var g = Math.Clamp(col.Y, 0f, 1f); + var b = Math.Clamp(col.Z, 0f, 1f); + var a = Math.Clamp(col.W, 0f, 1f); + return ComponentsToRgba( + (byte)Math.Round(r * 255), + (byte)Math.Round(g * 255), + (byte)Math.Round(b * 255), + (byte)Math.Round(a * 255) + ); + } + internal static uint Vector4ToAbgr(Vector4 col) { return RgbaToAbgr( diff --git a/HellionChat/Util/ImGuiUtil.cs b/HellionChat/Util/ImGuiUtil.cs index cc5187a..6714aef 100755 --- a/HellionChat/Util/ImGuiUtil.cs +++ b/HellionChat/Util/ImGuiUtil.cs @@ -26,234 +26,6 @@ internal static class ImGuiUtil Plugin = plugin; } - private static readonly ImGuiMouseButton[] Buttons = - [ - ImGuiMouseButton.Left, - ImGuiMouseButton.Middle, - ImGuiMouseButton.Right, - ]; - - private static Payload? Hovered; - private static Payload? LastLink; - private static readonly List<(Vector2, Vector2)> PayloadBounds = []; - - internal static void PostPayload(Chunk chunk, PayloadHandler? handler) - { - var payload = chunk.Link; - if (payload != null && ImGui.IsItemHovered()) - { - Hovered = payload; - ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); - handler?.Hover(payload); - } - else if (!ReferenceEquals(Hovered, payload)) - { - Hovered = null; - } - - if (handler == null) - return; - - foreach (var button in Buttons) - if (ImGui.IsItemClicked(button)) - handler.Click(chunk, payload, button); - } - - // Ceiling on the byte buffer for a single rendered line. UTF-8 takes at - // most 4 bytes per char; ImGui's internal ImString limit is well below - // this and FFXIV's chat lines top out around a few hundred chars in - // practice. The cap prevents an unbounded ArrayPool rent if a caller - // ever feeds in a degenerate input. - private const int MaxLineByteCount = 16 * 1024; - - internal static void WrapText( - string csText, - Chunk chunk, - PayloadHandler? handler, - Vector4 defaultText, - float lineWidth - ) - { - if (csText.Length == 0) - return; - - foreach (var part in csText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None)) - { - if (part.Length == 0) - { - ImGui.TextUnformatted(""); - continue; - } - - // Allocate against the encoder's own MaxByteCount so the buffer - // we hand to ImGui is sized by us. The actual byte count - // returned by GetBytes is then validated against that ceiling - // before any pointer arithmetic touches it; CodeQL recognises - // that comparison as a sanitiser for the - // cs/unvalidated-local-pointer-arithmetic taint flow. - var maxBytes = Encoding.UTF8.GetMaxByteCount(part.Length); - if (maxBytes <= 0 || maxBytes > MaxLineByteCount) - { - ImGui.TextUnformatted(""); - continue; - } - - var buffer = ArrayPool.Shared.Rent(maxBytes); - try - { - var written = Encoding.UTF8.GetBytes(part, 0, part.Length, buffer, 0); - if (written <= 0 || written > maxBytes) - { - ImGui.TextUnformatted(""); - continue; - } - - WrapEncodedLine(buffer.AsSpan(0, written), chunk, handler, defaultText, lineWidth); - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - } - - private static unsafe void WrapEncodedLine( - ReadOnlySpan bytes, - Chunk chunk, - PayloadHandler? handler, - Vector4 defaultText, - float lineWidth - ) - { - var byteCount = bytes.Length; - if (byteCount == 0) - { - ImGui.TextUnformatted(""); - return; - } - - fixed (byte* basePtr = bytes) - { - var widthLeft = ImGui.GetContentRegionAvail().X; - var endPrev = CalcWordWrap(basePtr, 0, byteCount, widthLeft); - if (endPrev < 0) - return; - - var firstSpace = FindFirstSpace(bytes, 0, byteCount); - var properBreak = firstSpace <= endPrev; - if (properBreak) - { - DrawText(basePtr, 0, endPrev, chunk, handler, defaultText); - } - else if (lineWidth == 0f) - { - ImGui.TextUnformatted(""); - } - else - { - // Check whether the next chunk would wrap at or past the - // first space. If yes, force a line break. - var wrapPos = CalcWordWrap(basePtr, 0, firstSpace, lineWidth); - if (wrapPos >= firstSpace) - ImGui.TextUnformatted(""); - } - - widthLeft = ImGui.GetContentRegionAvail().X; - var lineStart = 0; - while (endPrev < byteCount) - { - if (properBreak) - lineStart = endPrev; - - // Skip a leading space at the start of a wrapped line. - if (lineStart < byteCount && bytes[lineStart] == (byte)' ') - lineStart++; - - var newEnd = CalcWordWrap(basePtr, lineStart, byteCount, widthLeft); - if (properBreak && newEnd == endPrev) - break; - - if (newEnd < 0) - { - ImGui.TextUnformatted(""); - ImGui.TextUnformatted(""); - break; - } - - endPrev = newEnd; - DrawText(basePtr, lineStart, endPrev, chunk, handler, defaultText); - - if (!properBreak) - { - properBreak = true; - widthLeft = ImGui.GetContentRegionAvail().X; - } - } - } - } - - private static unsafe int CalcWordWrap(byte* basePtr, int start, int end, float width) - { - var result = ImGuiNative.CalcWordWrapPositionA( - ImGui.GetFont().Handle, - ImGuiHelpers.GlobalScale, - basePtr + start, - basePtr + end, - width - ); - if (result == null) - return -1; - return (int)(result - basePtr); - } - - private static unsafe void DrawText( - byte* basePtr, - int start, - int end, - Chunk chunk, - PayloadHandler? handler, - Vector4 defaultText - ) - { - var oldPos = ImGui.GetCursorScreenPos(); - - ImGuiNative.TextUnformatted(basePtr + start, basePtr + end); - PostPayload(chunk, handler); - - if (!ReferenceEquals(LastLink, chunk.Link)) - PayloadBounds.Clear(); - - LastLink = chunk.Link; - - if (Hovered != null && ReferenceEquals(Hovered, chunk.Link)) - { - defaultText.W = 0.25f; - var actualCol = ColourUtil.Vector4ToAbgr(defaultText); - ImGui - .GetWindowDrawList() - .AddRectFilled(oldPos, oldPos + ImGui.GetItemRectSize(), actualCol); - - foreach (var (boundsStart, boundsSize) in PayloadBounds) - ImGui - .GetWindowDrawList() - .AddRectFilled(boundsStart, boundsStart + boundsSize, actualCol); - - PayloadBounds.Clear(); - } - - if (Hovered == null && chunk.Link != null) - PayloadBounds.Add((oldPos, ImGui.GetItemRectSize())); - } - - private static int FindFirstSpace(ReadOnlySpan bytes, int start, int end) - { - for (var i = start; i < end; i++) - if (char.IsWhiteSpace((char)bytes[i])) - return i; - - return end; - } - // --------------------------------------------------------------- // Inspired by ChatTwo upstream f35b7d3 (Infiziert90, 2026-05-12). // Upstream dropped the width parameter (no callers there); we keep @@ -843,4 +615,234 @@ internal static class ImGuiUtil extraChatChannels.Remove(id); } } + + private static readonly ImGuiMouseButton[] Buttons = + [ + ImGuiMouseButton.Left, + ImGuiMouseButton.Middle, + ImGuiMouseButton.Right, + ]; + + // Payload interaction state shared between PostPayload and WrapText. + // Tracks the last hovered payload so hover-leave events can fire correctly. + private static Payload? Hovered; + private static Payload? LastLink; + private static readonly List<(Vector2, Vector2)> PayloadBounds = []; + + internal static void PostPayload(Chunk chunk, PayloadHandler? handler) + { + var payload = chunk.Link; + if (payload != null && ImGui.IsItemHovered()) + { + Hovered = payload; + ImGui.SetMouseCursor(ImGuiMouseCursor.Hand); + handler?.Hover(payload); + } + else if (!ReferenceEquals(Hovered, payload)) + { + Hovered = null; + } + + if (handler == null) + return; + + foreach (var button in Buttons) + if (ImGui.IsItemClicked(button)) + handler.Click(chunk, payload, button); + } + + // Ceiling on the byte buffer for a single rendered line. UTF-8 takes at + // most 4 bytes per char; ImGui's internal ImString limit is well below + // this and FFXIV's chat lines top out around a few hundred chars in + // practice. The cap prevents an unbounded ArrayPool rent if a caller + // ever feeds in a degenerate input. + private const int MaxLineByteCount = 16 * 1024; + + internal static void WrapText( + string csText, + Chunk chunk, + PayloadHandler? handler, + Vector4 defaultText, + float lineWidth + ) + { + if (csText.Length == 0) + return; + + foreach (var part in csText.Split(["\r\n", "\r", "\n"], StringSplitOptions.None)) + { + if (part.Length == 0) + { + ImGui.TextUnformatted(""); + continue; + } + + // Allocate against the encoder's own MaxByteCount so the buffer + // we hand to ImGui is sized by us. The actual byte count + // returned by GetBytes is then validated against that ceiling + // before any pointer arithmetic touches it; CodeQL recognises + // that comparison as a sanitiser for the + // cs/unvalidated-local-pointer-arithmetic taint flow. + var maxBytes = Encoding.UTF8.GetMaxByteCount(part.Length); + if (maxBytes <= 0 || maxBytes > MaxLineByteCount) + { + ImGui.TextUnformatted(""); + continue; + } + + var buffer = ArrayPool.Shared.Rent(maxBytes); + try + { + var written = Encoding.UTF8.GetBytes(part, 0, part.Length, buffer, 0); + if (written <= 0 || written > maxBytes) + { + ImGui.TextUnformatted(""); + continue; + } + + WrapEncodedLine(buffer.AsSpan(0, written), chunk, handler, defaultText, lineWidth); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + private static unsafe void WrapEncodedLine( + ReadOnlySpan bytes, + Chunk chunk, + PayloadHandler? handler, + Vector4 defaultText, + float lineWidth + ) + { + var byteCount = bytes.Length; + if (byteCount == 0) + { + ImGui.TextUnformatted(""); + return; + } + + fixed (byte* basePtr = bytes) + { + var widthLeft = ImGui.GetContentRegionAvail().X; + var endPrev = CalcWordWrap(basePtr, 0, byteCount, widthLeft); + if (endPrev < 0) + return; + + var firstSpace = FindFirstSpace(bytes, 0, byteCount); + var properBreak = firstSpace <= endPrev; + if (properBreak) + { + DrawText(basePtr, 0, endPrev, chunk, handler, defaultText); + } + else if (lineWidth == 0f) + { + ImGui.TextUnformatted(""); + } + else + { + // Check whether the next chunk would wrap at or past the + // first space. If yes, force a line break. + var wrapPos = CalcWordWrap(basePtr, 0, firstSpace, lineWidth); + if (wrapPos >= firstSpace) + ImGui.TextUnformatted(""); + } + + widthLeft = ImGui.GetContentRegionAvail().X; + var lineStart = 0; + while (endPrev < byteCount) + { + if (properBreak) + lineStart = endPrev; + + // Skip a leading space at the start of a wrapped line. + if (lineStart < byteCount && bytes[lineStart] == (byte)' ') + lineStart++; + + var newEnd = CalcWordWrap(basePtr, lineStart, byteCount, widthLeft); + if (properBreak && newEnd == endPrev) + break; + + if (newEnd < 0) + { + ImGui.TextUnformatted(""); + ImGui.TextUnformatted(""); + break; + } + + endPrev = newEnd; + DrawText(basePtr, lineStart, endPrev, chunk, handler, defaultText); + + if (!properBreak) + { + properBreak = true; + widthLeft = ImGui.GetContentRegionAvail().X; + } + } + } + } + + private static unsafe int CalcWordWrap(byte* basePtr, int start, int end, float width) + { + var result = ImGuiNative.CalcWordWrapPositionA( + ImGui.GetFont().Handle, + ImGuiHelpers.GlobalScale, + basePtr + start, + basePtr + end, + width + ); + if (result == null) + return -1; + return (int)(result - basePtr); + } + + private static unsafe void DrawText( + byte* basePtr, + int start, + int end, + Chunk chunk, + PayloadHandler? handler, + Vector4 defaultText + ) + { + var oldPos = ImGui.GetCursorScreenPos(); + + ImGuiNative.TextUnformatted(basePtr + start, basePtr + end); + PostPayload(chunk, handler); + + if (!ReferenceEquals(LastLink, chunk.Link)) + PayloadBounds.Clear(); + + LastLink = chunk.Link; + + if (Hovered != null && ReferenceEquals(Hovered, chunk.Link)) + { + defaultText.W = 0.25f; + var actualCol = ColourUtil.Vector4ToAbgr(defaultText); + ImGui + .GetWindowDrawList() + .AddRectFilled(oldPos, oldPos + ImGui.GetItemRectSize(), actualCol); + + foreach (var (boundsStart, boundsSize) in PayloadBounds) + ImGui + .GetWindowDrawList() + .AddRectFilled(boundsStart, boundsStart + boundsSize, actualCol); + + PayloadBounds.Clear(); + } + + if (Hovered == null && chunk.Link != null) + PayloadBounds.Add((oldPos, ImGui.GetItemRectSize())); + } + + private static int FindFirstSpace(ReadOnlySpan bytes, int start, int end) + { + for (var i = start; i < end; i++) + if (char.IsWhiteSpace((char)bytes[i])) + return i; + + return end; + } } diff --git a/HellionChat/Util/StringUtil.cs b/HellionChat/Util/StringUtil.cs index ccdcc98..efb593b 100755 --- a/HellionChat/Util/StringUtil.cs +++ b/HellionChat/Util/StringUtil.cs @@ -34,8 +34,7 @@ internal static class StringUtil // Returns the text unchanged when it already fits the width budget, // otherwise the longest prefix plus a horizontal-ellipsis character that - // still fits. Used by the chat header Honorific title slot and reused by - // the chat-line truncation path in later cycles. + // still fits. Used by the HonorificHeader title slot (HonorificHeader.Draw). public static string TruncateToFitWidth(string text, float maxWidth) { if (ImGui.CalcTextSize(text).X <= maxWidth) diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index 058bdfb..dc87ae2 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -1,3 +1,6 @@ +using HellionChat.Code; +using HellionChat.GameFunctions.Types; + namespace HellionChat.Util; // Pure predicates for the TempTab pin lifecycle. Extracted from the strip @@ -13,4 +16,82 @@ internal static class TabLifecycleHelpers public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t); public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t); + + // Stale-tell strip + channel derive, run at every tab activation. When a + // DIFFERENT tab becomes the input surface, drop any runtime tell state the + // game-side detour left on it (the CurrentChannel tell target plus the + // partner-name label) so a normal typed line cannot route as a silent /tell + // to the old partner — the same privacy guard StripTellBindingOnPromote + // applies on promote. Re-activating the already-active tab must NOT strip + // (a live game-tell would lose its context, TR-4); a tab carrying its own + // Tab.TellTarget is a real tell binding (leg1) and is left intact. + internal static void OnTabActivated(Tab tab, Tab? previous) + { + if ( + !ReferenceEquals(tab, previous) + && tab.CurrentChannel.Channel == InputChannel.Tell + && tab.TellTarget?.IsSet() != true + ) + { + tab.CurrentChannel.SetChannel(InputChannel.Invalid); + tab.CurrentChannel.TellTarget = null; + tab.CurrentChannel.ResetTempChannel(); + // Label chunks carry the partner name after a game-side tell. + tab.CurrentChannel.Name = []; + } + + EnsureCurrentChannel(tab); + } + + // Pure derive-helper: resolves a tab's input channel from its + // SelectedChannels when none is set yet. Reached only via OnTabActivated + // now, so the strip and the derive stay in lockstep at every entry. + internal static void EnsureCurrentChannel(Tab tab) + { + if (tab.CurrentChannel.Channel != InputChannel.Invalid) + return; + + foreach (var chatType in tab.SelectedChannels.Keys) + { + if (chatType.ToInputChannel() is { } input) + { + tab.CurrentChannel.SetChannel(input); + return; + } + } + } + + // Drops a temp/pinned tell tab's binding when it is promoted to a permanent + // tab. Beyond the obvious IsTempTab/IsPinned/Tab.TellTarget reset, this also + // clears the RUNTIME channel's tell state — that part is the CORR-1 guard: + // a spawned tell tab carries CurrentChannel.Channel == Tell plus a resolvable + // CurrentChannel.TellTarget, and neither is touched by clearing Tab.TellTarget + // alone. Without this clear the input bar would route a normal typed line on + // the promoted tab silently as /tell to the OLD partner (a privacy misfire the + // current==Tell routing gate cannot catch, because current here really IS + // Tell). Channel -> Invalid so the next sidebar/top-bar click re-derives the + // channel from SelectedChannels via EnsureCurrentChannel like any normal tab; + // the worst residual is a "/t" with no target, which the game rejects without + // sending (same safe class as the COMP-1 fall-through, no silent send). + internal static void StripTellBindingOnPromote(Tab tab) + { + tab.IsTempTab = false; + tab.IsPinned = false; + tab.TellTarget = TellTarget.Empty(); + tab.Channel = null; + tab.CurrentChannel.SetChannel(InputChannel.Invalid); + tab.CurrentChannel.TellTarget = null; + tab.CurrentChannel.ResetTempChannel(); + } + + // Wrap-around tab index for keybind cycling. Pure so the Build-Suite can test the + // wrap math without a live window. count == 0 returns 0 (the caller dead-zones + // before activating); negative deltas wrap correctly via the double-mod. + // TEST-MIRROR: ../../../Hellion Build test/_Helpers/TabLifecycleHelpersTests.cs + internal static int WrapTabIndex(int current, int delta, int count) + { + if (count <= 0) + return 0; + return ((current + delta) % count + count) % count; + } } diff --git a/HellionChat/_Helpers/CompactInputSubmitter.cs b/HellionChat/_Helpers/CompactInputSubmitter.cs deleted file mode 100644 index 546a9ae..0000000 --- a/HellionChat/_Helpers/CompactInputSubmitter.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using HellionChat.Ui; - -namespace HellionChat._Helpers; - -// Extracted submit logic from ChatInputBar.SubmitCompact to allow unit testing -// without a sealed ChatLogWindow dependency. -// TEST-MIRROR: ../../../Hellion Build test/Ui/CompactInputSubmitterTests.cs -public static class CompactInputSubmitter -{ - public static bool TrySubmit(InputState state, Tab tab, Action sender) - { - ArgumentNullException.ThrowIfNull(state); - ArgumentNullException.ThrowIfNull(tab); - ArgumentNullException.ThrowIfNull(sender); - - if (string.IsNullOrWhiteSpace(state.Buffer)) - return false; - - var text = state.Buffer; - state.Buffer = string.Empty; - state.HistoryCursor = -1; - sender(tab, text); - return true; - } -} diff --git a/repo.json b/repo.json index 51e45e2..76c2019 100644 --- a/repo.json +++ b/repo.json @@ -3,7 +3,7 @@ "Author": "Jon Kazama (Hellion Forge)", "Name": "Hellion Chat", "InternalName": "HellionChat", - "AssemblyVersion": "1.5.6.0", + "AssemblyVersion": "1.8.8.0", "Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with four profiles: Privacy-First, Casual, Roleplay, Full History\n- Multi-language UI (24 locales) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR", "ApplicableVersion": "any", "RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat", @@ -25,7 +25,7 @@ "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.5.6/latest.zip", - "TestingAssemblyVersion": "1.5.6.0", + "TestingAssemblyVersion": "1.8.8.0", "IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png", "ImageUrls": [ "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png",