Merge branch 'feature/v1.8.0' into main

This commit is contained in:
2026-06-16 09:18:12 +02:00
123 changed files with 11368 additions and 9624 deletions
+36
View File
@@ -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<string, TextCommand> Commands = BuildCommands();
private static Dictionary<string, TextCommand> BuildCommands()
{
var dict = new Dictionary<string, TextCommand>(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);
}
+62 -44
View File
@@ -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();
}
+3
View File
@@ -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
);
+61 -1
View File
@@ -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<Guid> 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
{
+56 -8
View File
@@ -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<ThemeTypography?>? _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<ThemeTypography?> 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;
+18
View File
@@ -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;
}
+36 -69
View File
@@ -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);
+33 -22
View File
@@ -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)
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Dalamud.NET.Sdk/15.0.0">
<PropertyGroup>
<!-- Independent versioning; see yaml changelog for upstream Chat 2 base -->
<Version>1.5.6</Version>
<Version>1.8.8</Version>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- Use lock file to pin exact versions -->
@@ -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;
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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,
+20 -9
View File
@@ -34,11 +34,13 @@ internal sealed class TypingIpc : IDisposable
private ChatInputState LastState;
private bool HasState;
private readonly Ui.Components.InputBar _inputBar;
private readonly ILogger<TypingIpc> _logger;
internal TypingIpc(Plugin plugin, ILogger<TypingIpc> logger)
internal TypingIpc(Plugin plugin, Ui.Components.InputBar inputBar, ILogger<TypingIpc> logger)
{
Plugin = plugin;
_inputBar = inputBar;
_logger = logger;
StateQueryGate = Plugin.Interface.GetIpcProvider<ChatInputState>(
@@ -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()
{
+71 -25
View File
@@ -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<Tab> 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<Tab> tabs,
Tab currentTab,
Message probe,
bool playSounds
) => SelectNotificationSound(tabs, currentTab, probe, playSounds);
internal class NameFormatting
{
internal string Before { get; private set; } = string.Empty;
Executable → Regular
+448 -411
View File
File diff suppressed because it is too large Load Diff
+129 -47
View File
@@ -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<PayloadHandler> 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<PluginLifecycle>();
_lifecycle.Host = _host;
@@ -288,18 +315,29 @@ public sealed class Plugin : IAsyncDalamudPlugin
ExtraChat = _host.Services.GetRequiredService<ExtraChat>();
HonorificService = _host.Services.GetRequiredService<Integrations.HonorificService>();
CustomAudioPlayer = _host.Services.GetRequiredService<Integrations.CustomAudioPlayer>();
StatusBar = _host.Services.GetRequiredService<Ui.StatusBar>();
MessageManager = _host.Services.GetRequiredService<MessageManager>();
AutoTellTabsService = _host.Services.GetRequiredService<AutoTellTabsService>();
ChatLogWindow = _host.Services.GetRequiredService<ChatLogWindow>();
SettingsWindow = _host.Services.GetRequiredService<SettingsWindow>();
InputBar = _host.Services.GetRequiredService<Ui.Components.InputBar>();
MainWindow = _host.Services.GetRequiredService<Ui.Windows.MainWindow>();
SettingsWindow = _host.Services.GetRequiredService<Ui.Windows.SettingsWindow>();
DbViewer = _host.Services.GetRequiredService<DbViewer>();
InputPreview = _host.Services.GetRequiredService<InputPreview>();
CommandHelpWindow = _host.Services.GetRequiredService<CommandHelpWindow>();
SeStringDebugger = _host.Services.GetRequiredService<SeStringDebugger>();
DebuggerWindow = _host.Services.GetRequiredService<DebuggerWindow>();
FirstRunWizard = _host.Services.GetRequiredService<FirstRunWizard>();
ChannelPopoutPool = _host.Services.GetRequiredService<Ui.Windows.ChannelPopoutPool>();
// 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<PayloadHandler>();
PayloadHandlerLender = _host.Services.GetRequiredService<Util.Lender<PayloadHandler>>();
ChunkRenderer = _host.Services.GetRequiredService<Ui.Components.ChunkRenderer>();
}
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();
+231 -11
View File
@@ -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<IDalamudPluginInterface>()
));
services.AddSingleton(_ => new StatusBar());
services.AddSingleton(sp => new IpcManager(sp.GetRequiredService<ILogger<IpcManager>>()));
services.AddSingleton(sp => new ExtraChat(sp.GetRequiredService<ILogger<ExtraChat>>()));
@@ -92,6 +100,11 @@ internal static class PluginHostFactory
sp.GetRequiredService<ILogger<ThemeRegistry>>()
));
services.AddSingleton(_ => new Ui.StyleEngine.TokenResolver());
services.AddSingleton(sp => new Ui.StyleEngine.PushStack(
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
));
services.AddSingleton(sp => new GameFunctions.GameFunctions(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ILogger<GameFunctions.GameFunctions>>(),
@@ -99,6 +112,7 @@ internal static class PluginHostFactory
));
services.AddSingleton(sp => new TypingIpc(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<Ui.Components.InputBar>(),
sp.GetRequiredService<ILogger<TypingIpc>>()
));
@@ -107,6 +121,118 @@ internal static class PluginHostFactory
sp.GetRequiredService<ILogger<Integrations.HonorificService>>(),
sp.GetRequiredService<IFramework>()
));
services.AddSingleton(sp => new Services.TellRouterService(
sp.GetRequiredService<MessageManager>(),
sp.GetRequiredService<ILogger<Services.TellRouterService>>()
));
services.AddSingleton(sp => new Ui.Components.HonorificHeader(
sp.GetRequiredService<Integrations.HonorificService>(),
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>()
));
services.AddSingleton(sp => new Ui.Components.Sidebar(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<ILogger<Ui.Components.Sidebar>>(),
sp.GetRequiredService<Ui.Windows.ChannelPopoutPool>()
));
services.AddSingleton(sp => new Ui.Components.MessageList(
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<Ui.Components.ChunkRenderer>()
));
services.AddSingleton(_ => new Ui.Components.SymbolPicker());
services.AddSingleton(sp => new Ui.Components.ThemeQuickPicker(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new Ui.Components.InputBar(
sp.GetRequiredService<Ui.Components.SymbolPicker>(),
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<ILogger<Ui.Components.InputBar>>(),
() => sp.GetRequiredService<Plugin>().SettingsWindow.Toggle(),
sp.GetRequiredService<Ui.CommandHelpWindow>(),
sp.GetRequiredService<Ui.Components.ThemeQuickPicker>(),
() => sp.GetRequiredService<Plugin>().MainWindow.UserHide()
));
services.AddSingleton(sp => new Ui.Components.Settings.TabSidebar(
sp.GetRequiredService<FontManager>()
));
services.AddSingleton(sp => new Ui.Components.Settings.ContentArea());
services.AddSingleton(sp => new Ui.Components.Settings.ThemePicker(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new Ui.Components.Settings.ColorPicker(
sp.GetRequiredService<ThemeRegistry>()
));
services.AddSingleton(sp => new Ui.Components.Settings.LivePreviewPanel(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<FontManager>()
));
services.AddSingleton(sp => new Ui.Components.Settings.ThemeImportExportRow(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<ILogger<Ui.Components.Settings.ThemeImportExportRow>>()
));
services.AddSingleton(sp => new Ui.Components.Settings.FontsSection(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<FontManager>()
));
services.AddSingleton(sp => new Ui.Components.Settings.ChatColourPicker(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ThemeRegistry>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AppearanceTab(
sp.GetRequiredService<Ui.Components.Settings.ThemePicker>(),
sp.GetRequiredService<Ui.Components.Settings.ColorPicker>(),
sp.GetRequiredService<Ui.Components.Settings.LivePreviewPanel>(),
sp.GetRequiredService<Ui.Components.Settings.ThemeImportExportRow>(),
sp.GetRequiredService<Ui.Components.Settings.FontsSection>(),
sp.GetRequiredService<Ui.Components.Settings.ChatColourPicker>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.GeneralTab(
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChatTab(
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.WindowTab(
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.ChannelsTab(
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.DataPrivacyTab(
sp.GetRequiredService<Plugin>()
));
services.AddSingleton(sp => new Ui.Components.Settings.Tabs.AboutTab(
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<Integrations.HonorificService>(),
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<IPlatformUtil>()
));
services.AddSingleton(sp => new Ui.Components.StatusBar(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<FontManager>()
));
services.AddSingleton(sp => new Ui.Components.TopTabBar(
sp.GetRequiredService<Ui.Windows.ChannelPopoutPool>()
));
services.AddSingleton(sp => new Ui.Windows.MainWindow(
sp.GetRequiredService<Ui.Components.HonorificHeader>(),
sp.GetRequiredService<Ui.Components.Sidebar>(),
sp.GetRequiredService<Ui.Components.TopTabBar>(),
sp.GetRequiredService<Ui.Components.MessageList>(),
sp.GetRequiredService<Ui.Components.InputBar>(),
sp.GetRequiredService<Ui.Components.StatusBar>(),
sp.GetRequiredService<Lender<PayloadHandler>>()
));
services.AddSingleton(sp => new Integrations.FailedTellNotifier(
sp.GetRequiredService<ILogger<Integrations.FailedTellNotifier>>()
));
@@ -134,25 +260,89 @@ internal static class PluginHostFactory
);
});
// Factory-lambdas for ChunkRenderer, PayloadHandler, and Lender<PayloadHandler>
// because all three are internal-sealed (ActivatorUtilities can't reflect into
// internal ctors) and Lender<T> has an internal ctor by design.
// PayloadHandler registered twice: once as singleton for G/H, once via Lender<T> for per-frame isolation (I/J/K).
services.AddSingleton(sp => new Ui.Components.ChunkRenderer(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<ILogger<Ui.Components.ChunkRenderer>>(),
sp.GetRequiredService<GameFunctions.GameFunctions>()
));
services.AddSingleton(sp => MakePayloadHandler(sp));
services.AddSingleton(sp => new Lender<PayloadHandler>(() => 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<Func<int, Ui.Windows.ChannelPopoutWindow>>(sp =>
slot => new Ui.Windows.ChannelPopoutWindow(
slot,
new Ui.Components.MessageList(
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<Ui.Components.ChunkRenderer>()
),
new Ui.Components.InputBar(
sp.GetRequiredService<Ui.Components.SymbolPicker>(),
sp.GetRequiredService<FontManager>(),
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<Ui.StyleEngine.TokenResolver>(),
sp.GetRequiredService<ILogger<Ui.Components.InputBar>>(),
() => sp.GetRequiredService<Plugin>().SettingsWindow.Toggle(),
sp.GetRequiredService<Ui.CommandHelpWindow>()
),
sp.GetRequiredService<ILogger<Ui.Windows.ChannelPopoutWindow>>(),
sp.GetRequiredService<FontManager>()
)
);
services.AddSingleton(sp => new Ui.Windows.ChannelPopoutPool(
sp.GetRequiredService<Func<int, Ui.Windows.ChannelPopoutWindow>>(),
sp.GetRequiredService<ILogger<Ui.Windows.ChannelPopoutPool>>()
));
// Block C — Windows. WindowSystem.AddWindow is called from
// PluginLifecycle.LoadAsync on the framework thread.
services.AddSingleton(sp => new ChatLogWindow(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ILogger<ChatLogWindow>>(),
sp.GetRequiredService<ILoggerFactory>()
));
services.AddSingleton(sp => new SettingsWindow(
services.AddSingleton(sp => new Ui.Windows.SettingsWindow(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<Ui.Components.Settings.TabSidebar>(),
sp.GetRequiredService<Ui.Components.Settings.ContentArea>(),
sp.GetRequiredService<Ui.Components.Settings.ThemePicker>(),
sp.GetRequiredService<Ui.Components.Settings.ColorPicker>(),
sp.GetRequiredService<Ui.Components.Settings.LivePreviewPanel>(),
sp.GetRequiredService<Ui.Components.Settings.Tabs.AppearanceTab>(),
sp.GetRequiredService<Ui.Components.Settings.Tabs.GeneralTab>(),
sp.GetRequiredService<Ui.Components.Settings.Tabs.ChatTab>(),
sp.GetRequiredService<Ui.Components.Settings.Tabs.WindowTab>(),
sp.GetRequiredService<Ui.Components.Settings.Tabs.ChannelsTab>(),
sp.GetRequiredService<Ui.Components.Settings.Tabs.DataPrivacyTab>(),
sp.GetRequiredService<Ui.Components.Settings.Tabs.AboutTab>(),
sp.GetRequiredService<ILoggerFactory>()
));
services.AddSingleton(sp => new DbViewer(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<ILogger<DbViewer>>()
));
services.AddSingleton(sp => new InputPreview(sp.GetRequiredService<ChatLogWindow>()));
services.AddSingleton(sp => new CommandHelpWindow(sp.GetRequiredService<ChatLogWindow>()));
services.AddSingleton(sp => new InputPreview(
sp.GetRequiredService<Ui.Components.ChunkRenderer>(),
sp.GetRequiredService<Lender<PayloadHandler>>(),
sp.GetRequiredService<Ui.Windows.MainWindow>(),
sp.GetRequiredService<Ui.Components.InputBar>(),
sp.GetRequiredService<ILogger<InputPreview>>()
));
// 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<Ui.Components.ChunkRenderer>(),
sp.GetRequiredService<ILogger<CommandHelpWindow>>()
));
services.AddSingleton(sp => new SeStringDebugger(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new DebuggerWindow(sp.GetRequiredService<Plugin>()));
services.AddSingleton(sp => new DebuggerWindow(
sp.GetRequiredService<Plugin>(),
sp.GetRequiredService<PayloadHandler>()
));
services.AddSingleton(sp => new FirstRunWizard(sp.GetRequiredService<Plugin>()));
// 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<ThemeRegistry>()
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<FontManager>()
));
services.AddHostedService(sp => new IpcManagerInitHostedService(
sp.GetRequiredService<IpcManager>()
@@ -178,12 +369,41 @@ internal static class PluginHostFactory
services.AddHostedService(sp => new AutoTellTabsServiceInitHostedService(
sp.GetRequiredService<AutoTellTabsService>()
));
// 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.TellRouterService>()
));
services.AddHostedService(
sp => new Infrastructure.Hosting.FailedTellNotifierInitHostedService(
sp.GetRequiredService<Integrations.FailedTellNotifier>()
)
);
services.AddHostedService(sp => new PayloadHandlerInitHostedService(
sp.GetRequiredService<PayloadHandler>(),
sp.GetRequiredService<Ui.Components.MessageList>()
));
services.AddHostedService(sp => new CommandHelpWindowInitHostedService(
sp.GetRequiredService<Ui.CommandHelpWindow>(),
sp.GetRequiredService<Ui.Windows.MainWindow>()
));
services.AddHostedService(sp => new ChannelPopoutInitHostedService(
sp.GetRequiredService<Ui.Windows.ChannelPopoutPool>(),
sp.GetRequiredService<PayloadHandler>()
));
}
private static PayloadHandler MakePayloadHandler(IServiceProvider sp) =>
new(
sp.GetRequiredService<ThemeRegistry>(),
sp.GetRequiredService<IpcManager>(),
sp.GetRequiredService<GameFunctions.GameFunctions>(),
sp.GetRequiredService<Ui.Components.InputBar>(),
sp.GetRequiredService<Ui.Windows.MainWindow>(),
sp.GetRequiredService<Ui.Components.ChunkRenderer>(),
sp.GetRequiredService<ILogger<PayloadHandler>>()
);
}
internal sealed record PluginHostDependencies(
+7 -2
View File
@@ -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()
@@ -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;
}
}
@@ -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() { }
}
@@ -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() { }
}
@@ -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() { }
}
@@ -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() { }
}
@@ -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;
}
}
@@ -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();
}
+101
View File
@@ -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() { }
}
@@ -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() { }
}
@@ -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;
}
}
@@ -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() { }
}
@@ -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() { }
}
@@ -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() { }
}
@@ -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, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
EnableNotificationSound = enableSound,
NotificationSoundId = soundId,
};
public void CleanUp() { }
}
@@ -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() { }
}
@@ -0,0 +1,69 @@
using Dalamud.Bindings.ImGui;
using Dalamud.Plugin.SelfTest;
namespace HellionChat.SelfTests;
// Drives the per-frame Lender<PayloadHandler> 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<T> 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<PayloadHandler>.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() { }
}
@@ -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() { }
}
@@ -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;
}
+51
View File
@@ -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<PayloadHandler>.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 '<Helper>' 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`.
@@ -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() { }
}
@@ -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<Chunk>
{
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() { }
}
@@ -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() { }
}
@@ -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, (ChatSource, ChatSource)>
{
[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() { }
}
@@ -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() { }
}
@@ -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<Tab>();
if (Plugin.Config.Tabs.All(t => t.IsTempTab))
{
injected.Add(
new Tab
{
Name = "Persistent Probe@SelfTest",
SelectedChannels = new Dictionary<ChatType, (ChatSource, ChatSource)>
{
[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, (ChatSource, ChatSource)>
{
[ChatType.TellIncoming] = (ChatSourceExt.All, ChatSourceExt.All),
[ChatType.TellOutgoing] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
}
@@ -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, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
var list = new List<Tab> { 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() { }
}
@@ -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() { }
}
@@ -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() { }
}
@@ -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, (ChatSource, ChatSource)>
{
[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, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
public void CleanUp() { }
}
@@ -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() { }
}
@@ -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() { }
}
@@ -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() { }
}
@@ -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() { }
}
+112
View File
@@ -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<TellRouterService> _logger;
private bool _initialized;
public TellRouterService(MessageManager messageManager, ILogger<TellRouterService> 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;
}
});
}
}
@@ -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
}
}
+160 -41
View File
@@ -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();
}
}
+24
View File
@@ -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);
}
}
+368 -20
View File
@@ -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<ThemeRegistry>? 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<string> 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<ChatType, uint>).
_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 <slug>_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 <slug>_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))
{
+1
View File
@@ -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
-15
View File
@@ -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;
}
}
-70
View File
@@ -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<uint> 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<string> 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)];
}
}
-251
View File
@@ -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<Tab?> _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<Tab?> 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;
}
File diff suppressed because it is too large Load Diff
+43 -19
View File
@@ -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<CommandHelpWindow> _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<CommandHelpWindow> 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);
}
}
+239
View File
@@ -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<ChunkRenderer> _logger;
private readonly GameFunctions.GameFunctions _gameFunctions;
private readonly string _salt;
public ChunkRenderer(
ThemeRegistry themes,
FontManager fonts,
ILogger<ChunkRenderer> 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<Chunk> 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}";
}
}
@@ -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));
}
}
@@ -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);
}
}
+820
View File
@@ -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<InputBar> _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<AutoTranslateEntry>? _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<InputBar> 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 <at:...> 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 <at:>
// 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 = $"<at:{entry.Group},{entry.Row}>";
_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;
}
}
+249
View File
@@ -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<Message> 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<Message> 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);
}
}
@@ -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();
}
}
@@ -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<ThemeColors, (string label, uint color)[]> slots,
Func<ThemeColors, (string label, uint color)[], ThemeColors> 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.");
}
}
}
@@ -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<string> renderTab)
{
using var child = ImRaii.Child("##settings-content", new Vector2(0, 0), true);
if (!child.Success)
{
return;
}
renderTab(activeTab);
}
}
@@ -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<ExtraGlyphRanges>())
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<SingleFontSpec> set,
Action reset,
string resetId,
Predicate<IFontFamilyId>? 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 };
}
@@ -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<string> 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<uint> 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));
}
}
@@ -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<string>? 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);
}
}
}
@@ -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<bool> get, Action<bool> 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");
}
}
@@ -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();
}
}
@@ -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<TellAutoOpenMode>();
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<bool> get, Action<bool> set)
{
var current = get();
if (ImGui.Checkbox(label, ref current))
{
set(current);
_plugin.SaveConfig();
}
}
private void DrawSliderInt(string label, Func<int> get, Action<int> set, int min, int max)
{
var current = get();
ImGui.SetNextItemWidth(200);
if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current);
_plugin.SaveConfig();
}
}
}
@@ -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<ChatType>())
{
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<CommandHelpSide>();
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<WorldSuffixMode>();
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<NameFormMode>();
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<bool> get, Action<bool> set)
{
var current = get();
if (ImGui.Checkbox(label, ref current))
{
set(current);
_plugin.SaveConfig();
}
}
}
@@ -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<ChatType>: iterate Enum.GetValues<ChatType>() for stable
// display order (HashSet itself has none); toggle membership via
// Contains/Add/Remove.
ImGui.TextUnformatted("Persist channels:");
foreach (var ct in Enum.GetValues<ChatType>())
{
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<bool> get, Action<bool> set)
{
var current = get();
if (ImGui.Checkbox(label, ref current))
{
set(current);
_plugin.SaveConfig();
}
}
private void DrawSliderInt(string label, Func<int> get, Action<int> set, int min, int max)
{
var current = get();
ImGui.SetNextItemWidth(200);
if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current);
_plugin.SaveConfig();
}
}
}
@@ -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<bool> get, Action<bool> set)
{
var current = get();
if (ImGui.Checkbox(label, ref current))
{
set(current);
_plugin.SaveConfig();
}
}
private void DrawSlider(string label, Func<float> get, Action<float> 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<ConfigKeyBind?> get,
Action<ConfigKeyBind?> 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();
}
}
}
@@ -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<PreviewPosition>();
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<bool> get, Action<bool> set)
{
var current = get();
if (ImGui.Checkbox(label, ref current))
{
set(current);
_plugin.SaveConfig();
}
}
private void DrawSlider(string label, Func<float> get, Action<float> 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<int> get, Action<int> set, int min, int max)
{
var current = get();
ImGui.SetNextItemWidth(200);
if (ImGui.SliderInt(label, ref current, min, max, "%d"))
{
set(current);
_plugin.SaveConfig();
}
}
}
@@ -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<ThemeImportExportRow> _logger;
private string _importPath = string.Empty;
public ThemeImportExportRow(ThemeRegistry themes, ILogger<ThemeImportExportRow> 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 <slug>_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 <slug>_imported_<N>.
// 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);
}
}
}
@@ -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,
@@ -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<string> 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<Theme> Resolve(IEnumerable<string> 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<Theme> 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();
}
}
}
+427
View File
@@ -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<string, FontAwesomeIcon> 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<Sidebar> _logger;
private readonly Windows.ChannelPopoutPool _pool;
public Sidebar(
ThemeRegistry themes,
TokenResolver resolver,
FontManager fonts,
ILogger<Sidebar> 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<Tab> 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<int> BuildRenderOrder(IList<Tab> tabs)
{
var persistent = new List<int>(tabs.Count);
var pinned = new List<int>();
var unpinned = new List<int>();
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,
};
}
@@ -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<Tab> 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("·");
}
@@ -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<uint> _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 =
+144
View File
@@ -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;
}
}
@@ -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<Theme> AllThemes()
{
var all = new List<Theme>();
foreach (var slug in ThemePicker.CategoryMapSlugs)
if (_themes.TryGet(slug, out var theme))
all.Add(theme);
all.AddRange(_themes.AllCustom());
return all;
}
}
+72
View File
@@ -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<Tab> 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();
}
}
+2 -2
View File
@@ -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())));
}
}
+13 -19
View File
@@ -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()}"
-17
View File
@@ -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;
}
}
+76 -187
View File
@@ -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<PayloadHandler> _handlerLender;
private readonly Windows.MainWindow _mainWindow;
private readonly Components.InputBar _inputBar;
private readonly ILogger<InputPreview> _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<PayloadHandler> handlerLender,
Windows.MainWindow mainWindow,
Components.InputBar inputBar,
ILogger<InputPreview> 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<Chunk> 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 += $"<at:{payload.Group},{payload.Key}>".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 += "<item>".Length;
else if (text.Link is MapLinkPayload)
CursorPosition += "<flag>".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();
}
-271
View File
@@ -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<Popout> _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<Popout> 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);
}
}
-314
View File
@@ -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<ISettingsTab> 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<Appearance>()),
new Chat(Plugin, Mutable),
new SettingsTabs.Window(Plugin, Mutable),
new SettingsTabs.Tabs(Plugin, Mutable),
new DataAndPrivacy(Plugin, Mutable, loggerFactory.CreateLogger<DataAndPrivacy>()),
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;
}
}
-132
View File
@@ -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);
}
}
-493
View File
@@ -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<string> 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);
}
}
}
}
-695
View File
@@ -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<Appearance> _logger;
private string? _applyDismissedFor;
public string Name => HellionStrings.Settings_Tab_Appearance + "###tabs-appearance";
internal Appearance(Plugin plugin, Configuration mutable, ILogger<Appearance> 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<Theme> 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<ExtraGlyphRanges>())
{
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);
}
}
}
-423
View File
@@ -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<WorldSuffixMode>())
{
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<NameFormMode>())
{
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<PreviewPosition>())
{
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);
}
}
}

Some files were not shown because too many files have changed in this diff Show More