Merge branch 'feature/v1.8.0' into main
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+129
-47
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-10
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()}"
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user