diff --git a/.github/forge-posts/v1.4.7.md b/.github/forge-posts/v1.4.7.md
new file mode 100644
index 0000000..24f62df
--- /dev/null
+++ b/.github/forge-posts/v1.4.7.md
@@ -0,0 +1,29 @@
+---
+subtitle: Backlog Cleanup and Mid-Features
+versionsnatur: Mid-Feature-Patch
+---
+
+Achter Sub-Patch der v1.4.x Polish-Sweep-Serie. Erstes User-sichtbares Feature-Bundle seit v1.4.5 — angepinnte Tell-Tabs
+die Relog überleben, opt-in Honorific-Glow, plus eine konfigurierbare Sidebar.
+
+- **TempTell anpinnen**: Rechtsklick auf einen TempTell-Tab in der Sidebar → „Tab anpinnen". Angepinnte Tabs überleben
+ Plugin-Reload und Char-Logout, behalten ihre Konversations-Historie (wird beim Rehydrate aus dem MessageStore
+ nachgeladen) und bleiben an die gleiche /tell-Person gebunden. Hard-Cap 5 angepinnte Tabs in einem separaten Pool —
+ die normalen Auto-Tell-Tabs (15er Cap) sind davon entkoppelt, Gesamt-Decke 20. Die Sidebar gruppiert angepinnte Tabs
+ in einer eigenen „Angepinnt"-Sektion mit eigenem Trenner.
+- **Honorific Glow-Outline**: rendert jetzt eine 8-Richtungs-DrawList-Outline wenn der Honorific-Titel eine Glow-Farbe
+ trägt. Opt-in via **Settings → Integrationen → Glow-Outline rendern (Honorific)** (Default OFF). Gradient (Color3 /
+ GradientColourSet / Wave / Pulse) wird geparst und im DTO weitergereicht, rendert aktuell aber statisch als
+ Primärfarbe — der volle Gradient-Port (Animations-Algorithmus + Pride-Palette) kommt als eigener Cycle nach.
+- **Sidebar-Breite konfigurierbar**: in **Theme & Layout** ein Slider 44–160 px. Default bleibt 44 px (icon-only), aber
+ breiter machen damit Sektion-Header wie „Aktive Tells (3)" oder „Angepinnt (2)" nicht abgeschnitten werden.
+- **Settings-Save Channel-Fix**: ein Save mit aktivem Party- oder Linkshell-Tab konnte den Chat-Input zurück auf
+ `/tell ` springen lassen. `Configuration.UpdateFrom` bewahrt jetzt den Runtime-`CurrentChannel`
+ über den persistent-Tab-Merge hinweg, und `TabSwitched` deep-cloned den Seed-Channel statt sich den `UsedChannel` mit
+ dem vorigen Tab zu teilen.
+- **Internal**: `IPluginLogProxy`-Indirektion vor Dalamud's `IPluginLog` über alle ~91 `Plugin.Log`-Call-Sites. Damit
+ läuft `MessageStore.Migrate0` voll-isoliert in xUnit (F12.1-Lücke aus v1.4.6 geschlossen). Plus: TempTab-Counter als
+ abgeleitete Property statt gecachtes Interlocked-Feld — die neuen Pin/Unpin-Übergänge sind Cold-Path, kein
+ Lock-Free-Vorteil mehr. Migration v16 → v17 ist rein additiv (neues `Tab.IsPinned`-Bool, Default false).
+
+Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs
index a3952b9..d354bac 100644
--- a/HellionChat/AutoTellTabsService.cs
+++ b/HellionChat/AutoTellTabsService.cs
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
+using Dalamud.Interface.ImGuiNotification;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
@@ -20,13 +21,11 @@ internal sealed class AutoTellTabsService : IDisposable
private readonly MessageStore _store;
private readonly object _tempTabsLock = new();
- // F2.1: lock-free counter mirrors Config.Tabs.Count(IsTempTab) so the
- // hot-path getter doesn't contend with HandleTell on every render frame.
- // Bumped from inside the existing mutation paths so it stays consistent
- // with the underlying list — see SpawnTempTab, DropOldestTempTab, OnLogout
- // and ResyncTempTabCounter (used by Plugin.cs snapshot-restore).
- // TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs
- private int _activeTempTabCount;
+ // Hard cap on pinned TempTabs so the sidebar doesn't inflate over years
+ // of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live
+ // in their own bucket. A configurable cap is a vault-backlog anchor for
+ // a later cycle if tester feedback demands it.
+ internal const int MaxPinnedTempTabs = 5;
private bool _initialized;
@@ -37,7 +36,14 @@ internal sealed class AutoTellTabsService : IDisposable
_store = store;
}
- internal int ActiveTempTabCount => Volatile.Read(ref _activeTempTabCount);
+ // Derived from the tab list on read. Pin/Unpin/Promote/Logout simply
+ // mutate IsPinned or remove tabs — the count adapts automatically.
+ // Replaces the F2.1 Interlocked counter because the new pin-state
+ // transitions are cold-path and don't need lock-free reads.
+ internal int ActiveTempTabCount =>
+ Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInUnpinnedPool);
+
+ internal int PinnedTempTabCount => Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
internal void Initialize()
{
@@ -46,23 +52,51 @@ internal sealed class AutoTellTabsService : IDisposable
return;
}
- // Seed the counter from the persisted Tabs list so a config that already
- // contains TempTabs from a prior session starts in sync. Plugin.cs:168
- // crash-recovery has already dropped TempTabs by the time we get here,
- // so the snapshot reflects post-recovery reality.
- Interlocked.Exchange(ref _activeTempTabCount, Plugin.Config.Tabs.Count(t => t.IsTempTab));
+ // Pinned tabs come out of the JSON with TellTarget set but
+ // CurrentChannel reset (NonSerialized). Without re-seeding, the chat
+ // input has no tell-target on the active pinned tab, and the
+ // game-side channel hook only repaints CurrentChannel once the user
+ // triggers a /tell or channel switch.
+ RehydratePinnedTabs();
_messageManager.MessageProcessed += HandleTell;
Plugin.ClientState.Logout += OnLogout;
_initialized = true;
}
- // F2.1: callable from outside paths that mutate Config.Tabs directly
- // (Plugin.cs snapshot-restore). Atomically re-pegs the counter to the
- // live IsTempTab count.
- internal void ResyncTempTabCounter()
+ private void RehydratePinnedTabs()
{
- Interlocked.Exchange(ref _activeTempTabCount, Plugin.Config.Tabs.Count(t => t.IsTempTab));
+ var pinned = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
+ Plugin.LogProxy.Debug($"[Pin] Rehydrate scan: {pinned} pinned tab(s) found");
+
+ foreach (var tab in Plugin.Config.Tabs)
+ {
+ if (!TabLifecycleHelpers.IsInPinnedPool(tab))
+ continue;
+
+ if (tab.TellTarget is null || !tab.TellTarget.IsSet())
+ {
+ Plugin.LogProxy.Warning(
+ $"[Pin] Pinned tab '{tab.Name}' has no usable TellTarget "
+ + $"(Name={tab.TellTarget?.Name ?? ""} World={tab.TellTarget?.World ?? 0}). "
+ + "Chat input on this tab will be empty until the partner sends a tell or you /tell manually."
+ );
+ continue;
+ }
+
+ tab.Channel ??= InputChannel.Tell;
+ tab.CurrentChannel.Channel = InputChannel.Tell;
+ tab.CurrentChannel.TellTarget = tab.TellTarget.Clone();
+
+ // MessageList is NonSerialized so pinned tabs come back empty.
+ // Preload the same history window the spawn path uses so the user
+ // sees the recent conversation, not a blank tab.
+ PreloadHistory(tab, tab.TellTarget.Name, tab.TellTarget.World, Guid.Empty);
+
+ Plugin.LogProxy.Debug(
+ $"[Pin] Rehydrated '{tab.Name}' -> Tell target {tab.TellTarget.Name}@{tab.TellTarget.World}"
+ );
+ }
}
public void Dispose()
@@ -96,7 +130,7 @@ internal sealed class AutoTellTabsService : IDisposable
if (partner == null)
{
// Diagnostics: helps detect regressions (FFXIV payload changes, new edge cases)
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
$"[AutoTellTabs] Could not extract tell partner. type={message.Code.Type}, "
+ $"senderChunks={message.Sender.Count}, contentChunks={message.Content.Count}, "
+ $"senderSourcePayloads={message.SenderSource?.Payloads?.Count ?? 0}, "
@@ -110,7 +144,23 @@ internal sealed class AutoTellTabsService : IDisposable
var existing = FindTempTab(partner.Value.Name, partner.Value.World);
if (existing != null)
{
- // Already routed via MessageManager pipeline
+ // Already routed via MessageManager pipeline. Repair the
+ // tell-target if the fallback hit a pinned tab whose
+ // TellTarget didn't survive a previous round-trip — keeps
+ // FindTempTab fast on the next message.
+ if (
+ existing.IsPinned
+ && (existing.TellTarget is null || !existing.TellTarget.IsSet())
+ )
+ {
+ existing.TellTarget = new TellTarget(
+ partner.Value.Name,
+ partner.Value.World,
+ 0,
+ TellReason.Direct
+ );
+ _plugin.SaveConfig();
+ }
return;
}
@@ -160,22 +210,35 @@ internal sealed class AutoTellTabsService : IDisposable
return null;
}
- private Tab? FindTempTab(string name, uint world)
+ private static Tab? FindTempTab(string name, uint world)
{
- return Plugin.Config.Tabs.FirstOrDefault(t =>
+ var byTarget = Plugin.Config.Tabs.FirstOrDefault(t =>
t.IsTempTab
&& t.TellTarget != null
&& string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase)
&& t.TellTarget.World == world
);
+ if (byTarget != null)
+ return byTarget;
+
+ // Fallback: match by tab name. Pinned tabs are named via
+ // FormatTabName(player, world) at spawn time, so the name is a
+ // stable secondary key when TellTarget didn't survive a save/load
+ // (older configs from a renamed pin, malformed migrations, etc.).
+ var expectedName = FormatTabName(name, world);
+ return Plugin.Config.Tabs.FirstOrDefault(t =>
+ t.IsTempTab && string.Equals(t.Name, expectedName, StringComparison.OrdinalIgnoreCase)
+ );
}
- private void DropOldestTempTab()
+ internal void DropOldestTempTab()
{
- // Prioritize greeted tabs for drop; within each bucket, drop by oldest LastActivity
+ // Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
+ // never drop candidates. They leave the bucket only via Unpin or
+ // PromoteToPermanent.
var victim = Plugin
.Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx))
- .Where(t => t.Tab.IsTempTab)
+ .Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab))
.OrderByDescending(t => t.Tab.IsGreeted)
.ThenBy(t => t.Tab.LastActivity)
.FirstOrDefault();
@@ -198,7 +261,6 @@ internal sealed class AutoTellTabsService : IDisposable
}
Plugin.Config.Tabs.RemoveAt(victim.Index);
- Interlocked.Decrement(ref _activeTempTabCount);
// Re-anchor active tab to avoid silent switch when tab is dropped
if (victim.Index <= _plugin.LastTab)
@@ -223,7 +285,6 @@ internal sealed class AutoTellTabsService : IDisposable
}
Plugin.Config.Tabs.Add(tab);
- Interlocked.Increment(ref _activeTempTabCount);
}
private static Tab BuildTempTab(string playerName, uint worldRowId)
@@ -300,7 +361,7 @@ internal sealed class AutoTellTabsService : IDisposable
catch (Exception ex)
{
// Non-fatal: tab still spawns with visible error notice instead of silent history loss
- Plugin.Log.Error(ex, "[AutoTellTabs] History preload failed");
+ Plugin.LogProxy.Error(ex, "[AutoTellTabs] History preload failed");
tab.Messages.AddPrune(
MakeSystemMarker(HellionStrings.AutoTellTabs_HistoryLoadError),
MessageManager.MessageDisplayLimit
@@ -354,14 +415,16 @@ internal sealed class AutoTellTabsService : IDisposable
{
lock (_tempTabsLock)
{
- // Snapshot active tab index before mutating list
+ // 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 currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab;
+ var currentWasUnpinnedTempTab =
+ lastIndexValid
+ && TabLifecycleHelpers.IsInUnpinnedPool(Plugin.Config.Tabs[lastIndex]);
- // Clean up pop-out windows before removing temp tabs
var poppedTempTabIds = Plugin
- .Config.Tabs.Where(t => t.IsTempTab && t.PopOut)
+ .Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut)
.Select(t => t.Identifier)
.ToList();
if (poppedTempTabIds.Count > 0)
@@ -377,15 +440,78 @@ internal sealed class AutoTellTabsService : IDisposable
}
}
- var removed = Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab);
- Interlocked.Add(ref _activeTempTabCount, -removed);
+ Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
- // Force switch to tab 0 if active tab was temp or index is now out of range
+ // 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 (currentWasTempTab || !stillValid)
+ if (currentWasUnpinnedTempTab || !stillValid)
{
_plugin.WantedTab = 0;
}
}
}
+
+ internal bool TryPin(Tab tab)
+ {
+ if (!tab.IsTempTab || tab.IsPinned)
+ {
+ Plugin.LogProxy.Debug(
+ $"[Pin] TryPin skipped: IsTempTab={tab.IsTempTab} IsPinned={tab.IsPinned}"
+ );
+ return false;
+ }
+
+ if (PinnedTempTabCount >= MaxPinnedTempTabs)
+ {
+ WrapperUtil.AddNotification(
+ string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
+ NotificationType.Warning
+ );
+ return false;
+ }
+
+ tab.IsPinned = true;
+ Plugin.LogProxy.Debug(
+ $"[Pin] Pinned tab '{tab.Name}' target={tab.TellTarget?.Name}@{tab.TellTarget?.World}"
+ );
+ _plugin.SaveConfig();
+ return true;
+ }
+
+ internal void Unpin(Tab tab)
+ {
+ if (!tab.IsPinned)
+ {
+ return;
+ }
+
+ // If the unpinned pool is already full, dropping the oldest before
+ // flipping the flag avoids counting the just-unpinned tab as a drop
+ // candidate.
+ if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
+ {
+ DropOldestTempTab();
+ }
+
+ tab.IsPinned = false;
+ Plugin.LogProxy.Debug("[Pin] Unpinned tab '{tab.Name}'");
+ _plugin.SaveConfig();
+ }
+
+ internal void PromoteToPermanent(Tab tab)
+ {
+ if (!tab.IsTempTab)
+ {
+ return;
+ }
+
+ tab.IsTempTab = false;
+ tab.IsPinned = false;
+ tab.TellTarget = TellTarget.Empty();
+ Plugin.LogProxy.Debug(
+ $"[Pin] Promoted tab '{tab.Name}' to permanent (tell-binding dropped)"
+ );
+ _plugin.SaveConfig();
+ }
}
diff --git a/HellionChat/Commands.cs b/HellionChat/Commands.cs
index 7d618d5..dfe79c0 100755
--- a/HellionChat/Commands.cs
+++ b/HellionChat/Commands.cs
@@ -52,7 +52,7 @@ internal sealed class Commands : IDisposable
{
if (!Registered.TryGetValue(command, out var wrapper))
{
- Plugin.Log.Warning($"Missing registration for command {command}");
+ Plugin.LogProxy.Warning($"Missing registration for command {command}");
return;
}
@@ -62,7 +62,7 @@ internal sealed class Commands : IDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, $"Error while executing command {command}");
+ Plugin.LogProxy.Error(ex, $"Error while executing command {command}");
}
}
}
diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs
index fee30ca..2cc1cda 100755
--- a/HellionChat/Configuration.cs
+++ b/HellionChat/Configuration.cs
@@ -34,7 +34,7 @@ public class ConfigKeyBind
[Serializable]
public class Configuration : IPluginConfiguration
{
- private const int LatestVersion = 16;
+ private const int LatestVersion = 17;
public int Version { get; set; } = LatestVersion;
@@ -83,7 +83,7 @@ public class Configuration : IPluginConfiguration
// silently, like before.
if (!Enum.IsDefined(typeof(ChatType), type) && _warnedUnknownChannels.Add(type))
{
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
"PrivacyFilter: unrecognised ChatType {Type} — falling back to PrivacyPersistUnknownChannels={Persist}.",
type,
PrivacyPersistUnknownChannels
@@ -102,10 +102,22 @@ public class Configuration : IPluginConfiguration
public bool FirstRunCompleted;
public bool UseHellionFont = true;
public bool ShowHonorificTitleInHeader = true;
+
+ // v1.4.7 opt-in: renders the Honorific glow outline when the title carries
+ // a Glow colour. Default OFF — keeps v1.4.6 visuals untouched for users
+ // who don't care, and dodges the per-frame DrawList overhead on low-end
+ // hardware. Gradient (Color3 / GradientColourSet) is parsed but rendered
+ // as the primary Color until a later cycle ports the animation.
+ public bool ShowHonorificGlow;
public bool EnableAutoTellTabs = true;
public int AutoTellTabsLimit = 15;
public bool AutoTellTabsCompactDisplay;
public int AutoTellTabsHistoryPreload = 20;
+
+ // Sidebar width in pixels. Default 44 mirrors the icon-only layout from
+ // v1.2.0; users can widen up to 160 to fit a section-header line like
+ // "Active Tells (3)" without truncation.
+ public int SidebarWidth = 44;
public bool AutoTellTabsShowGreetedToggle;
public bool SeenPopOutInputHint;
public bool PopOutInputEnabled = true;
@@ -278,16 +290,20 @@ public class Configuration : IPluginConfiguration
ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton;
// Keep live temp tabs alive across UpdateFrom — a settings save must
- // not destroy open tell conversations. For persistent tabs, capture
- // the live MessageList and LastSendUnread by Identifier before the
- // replace and restore them onto the freshly cloned tabs; new tabs
- // get an empty MessageList, deleted tabs lose their history (intended).
- var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList();
- var livePersistentSession = Tabs.Where(t => !t.IsTempTab)
- .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread));
+ // not destroy open tell conversations. Pinned TempTabs are persistent
+ // and come through `other` like regular tabs; unpinned TempTabs are
+ // session-only and held from the local state. For persistent tabs
+ // (incl. pinned), capture live runtime state by Identifier and restore
+ // it onto the freshly cloned tabs — CurrentChannel is critical because
+ // the user may have switched channel in-game between settings-open
+ // and settings-save, and we'd otherwise overwrite that with the
+ // settings-time snapshot.
+ var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
+ var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t))
+ .ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread, t.CurrentChannel));
Tabs = other
- .Tabs.Where(t => !t.IsTempTab)
+ .Tabs.Where(t => !t.IsTempTab || t.IsPinned)
.Select(t =>
{
var clone = t.Clone();
@@ -295,11 +311,12 @@ public class Configuration : IPluginConfiguration
{
clone.Messages = live.Messages;
clone.LastSendUnread = live.LastSendUnread;
+ clone.CurrentChannel = live.CurrentChannel;
}
return clone;
})
.ToList();
- Tabs.AddRange(liveTempTabs);
+ Tabs.AddRange(liveUnpinnedTempTabs);
ChatTabForward = other.ChatTabForward;
ChatTabBackward = other.ChatTabBackward;
@@ -319,6 +336,7 @@ public class Configuration : IPluginConfiguration
FirstRunCompleted = other.FirstRunCompleted;
UseHellionFont = other.UseHellionFont;
ShowHonorificTitleInHeader = other.ShowHonorificTitleInHeader;
+ ShowHonorificGlow = other.ShowHonorificGlow;
// v1.1.0 theme engine fields
Theme = other.Theme;
@@ -330,6 +348,7 @@ public class Configuration : IPluginConfiguration
AutoTellTabsLimit = other.AutoTellTabsLimit;
AutoTellTabsCompactDisplay = other.AutoTellTabsCompactDisplay;
AutoTellTabsHistoryPreload = other.AutoTellTabsHistoryPreload;
+ SidebarWidth = other.SidebarWidth;
AutoTellTabsShowGreetedToggle = other.AutoTellTabsShowGreetedToggle;
SeenPopOutInputHint = other.SeenPopOutInputHint;
@@ -404,6 +423,11 @@ public class Tab
public bool HideWhenInactive;
public bool IsTempTab;
+
+ // Pinned TempTabs survive plugin reload and logout — tester feedback from
+ // Jin (v1.4.7). Pinned tabs live in their own pool (MaxPinnedTempTabs)
+ // separate from the AutoTellTabsLimit bucket.
+ public bool IsPinned;
public bool AllSenderMessages;
public TellTarget TellTarget = TellTarget.Empty();
@@ -511,6 +535,7 @@ public class Tab
HideInBattle = HideInBattle,
HideWhenInactive = HideWhenInactive,
IsTempTab = IsTempTab,
+ IsPinned = IsPinned,
AllSenderMessages = AllSenderMessages,
TellTarget = TellTarget.Clone(),
IsGreeted = IsGreeted,
diff --git a/HellionChat/EmoteCache.cs b/HellionChat/EmoteCache.cs
index 320006f..0af31b7 100644
--- a/HellionChat/EmoteCache.cs
+++ b/HellionChat/EmoteCache.cs
@@ -101,7 +101,10 @@ public static class EmoteCache
t =>
{
if (t.IsFaulted)
- Plugin.Log.Error(t.Exception!, $"EmoteCache load failed for {emoteCode}");
+ Plugin.LogProxy.Error(
+ t.Exception!,
+ $"EmoteCache load failed for {emoteCode}"
+ );
},
TaskScheduler.Default
)
@@ -158,7 +161,7 @@ public static class EmoteCache
{
// Reset to Unloaded so a later trigger can retry without a plugin reload.
State = LoadingState.Unloaded;
- Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized");
+ Plugin.LogProxy.Error(ex, "BetterTTV cache wasn't initialized");
}
}
@@ -214,7 +217,7 @@ public static class EmoteCache
}
catch
{
- Plugin.Log.Error("Failed to convert");
+ Plugin.LogProxy.Error("Failed to convert");
return null;
}
}
@@ -304,7 +307,7 @@ public static class EmoteCache
catch (Exception ex)
{
Failed = true;
- Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
+ Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
}
}
@@ -408,7 +411,7 @@ public static class EmoteCache
catch (Exception ex)
{
Failed = true;
- Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
+ Plugin.LogProxy.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
}
}
}
diff --git a/HellionChat/FontManager.cs b/HellionChat/FontManager.cs
index 809846f..9a25dcd 100644
--- a/HellionChat/FontManager.cs
+++ b/HellionChat/FontManager.cs
@@ -58,7 +58,7 @@ public class FontManager
);
if (stream is null)
{
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
"Hellion font resource missing — falling back to system default font."
);
return null;
@@ -237,7 +237,7 @@ public class FontManager
// Atlas-toolkit throws span IO and validation failures; routing the
// wider set through the fallback keeps a corrupt font config from
// taking down the whole atlas build.
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
e,
$"Configured {slot} font failed to load ({e.GetType().Name}), "
+ "falling back to NotoSansCjkRegular"
diff --git a/HellionChat/GameFunctions/Chat.cs b/HellionChat/GameFunctions/Chat.cs
index 4d75d99..aa6502c 100755
--- a/HellionChat/GameFunctions/Chat.cs
+++ b/HellionChat/GameFunctions/Chat.cs
@@ -236,7 +236,7 @@ internal sealed unsafe class Chat : IDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error in chat Activated event");
+ Plugin.LogProxy.Error(ex, "Error in chat Activated event");
}
});
}
@@ -266,7 +266,7 @@ internal sealed unsafe class Chat : IDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error in chat Activated event");
+ Plugin.LogProxy.Error(ex, "Error in chat Activated event");
}
return 1; // Prevent vanilla chat log from gaining focus
@@ -299,7 +299,7 @@ internal sealed unsafe class Chat : IDisposable
{
playerName = SeString.Parse(agent->TellPlayerName).TextValue;
worldId = agent->TellWorldId;
- Plugin.Log.Debug($"Detected tell target '[redacted]'@{worldId}");
+ Plugin.LogProxy.Debug($"Detected tell target '[redacted]'@{worldId}");
}
Plugin.CurrentTab.CurrentChannel = new UsedChannel
@@ -358,7 +358,7 @@ internal sealed unsafe class Chat : IDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error in chat Activated event");
+ Plugin.LogProxy.Error(ex, "Error in chat Activated event");
}
}
@@ -408,7 +408,7 @@ internal sealed unsafe class Chat : IDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error in chat Activated event");
+ Plugin.LogProxy.Error(ex, "Error in chat Activated event");
}
}
@@ -624,7 +624,7 @@ internal sealed unsafe class Chat : IDisposable
if (contentId == 0)
{
Plugin.ChatGui.PrintError(Language.Chat_SendTell_Error);
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
"Tried to send a tell with ContentId being 0, sorry this is an internal error."
);
return;
diff --git a/HellionChat/GameFunctions/GameFunctions.cs b/HellionChat/GameFunctions/GameFunctions.cs
index 770e6f6..985fa16 100755
--- a/HellionChat/GameFunctions/GameFunctions.cs
+++ b/HellionChat/GameFunctions/GameFunctions.cs
@@ -215,7 +215,7 @@ internal unsafe class GameFunctions : IDisposable
}
catch (Exception e)
{
- Plugin.Log.Warning(e, "Unable to open adventurer plate");
+ Plugin.LogProxy.Warning(e, "Unable to open adventurer plate");
return false;
}
}
@@ -255,7 +255,7 @@ internal unsafe class GameFunctions : IDisposable
var byteCount = System.Text.Encoding.UTF8.GetByteCount(ReplacementName);
if (byteCount >= PlaceholderBufferSize)
{
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
$"Replacement name too long for placeholder buffer ({byteCount} bytes >= {PlaceholderBufferSize}); falling back to original."
);
ReplacementName = null;
diff --git a/HellionChat/GameFunctions/KeybindManager.cs b/HellionChat/GameFunctions/KeybindManager.cs
index 4b1bc78..f374f2a 100644
--- a/HellionChat/GameFunctions/KeybindManager.cs
+++ b/HellionChat/GameFunctions/KeybindManager.cs
@@ -507,7 +507,7 @@ internal unsafe class KeybindManager : IDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error in chat Activated event");
+ Plugin.LogProxy.Error(ex, "Error in chat Activated event");
}
}
diff --git a/HellionChat/HellionChat.csproj b/HellionChat/HellionChat.csproj
index 43c63fa..3d11f01 100644
--- a/HellionChat/HellionChat.csproj
+++ b/HellionChat/HellionChat.csproj
@@ -1,7 +1,7 @@
- 1.4.6
+ 1.4.7
enable
enable
diff --git a/HellionChat/HellionChat.yaml b/HellionChat/HellionChat.yaml
index 549b301..992b5f4 100755
--- a/HellionChat/HellionChat.yaml
+++ b/HellionChat/HellionChat.yaml
@@ -35,6 +35,49 @@ tags:
- Replacement
- Privacy
changelog: |-
+ **v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**
+
+ Eighth sub-patch of the v1.4.x polish-sweep series. First
+ user-visible feature bundle since v1.4.5 — pinned tell tabs that
+ survive relog, opt-in Honorific glow rendering, and a configurable
+ sidebar.
+
+ - TempTell Pin: right-click a TempTell tab in the sidebar to pin
+ it. Pinned tabs survive relog, keep their conversation history
+ (loaded on demand from the message store), and stay bound to
+ the same /tell partner. Hard cap of 5 pinned tabs in a pool
+ separate from the 15-tab auto-tell pool — total ceiling is 20
+ tabs. New 'Pinned' section in the sidebar with its own divider
+ header
+ - Honorific Glow outline now renders when the title carries a
+ Glow colour. Opt-in via Settings → Integrations → 'Render glow
+ outlines (Honorific)' (default off, dodges the per-frame
+ DrawList overhead on low-end hardware). Gradient (Color3 /
+ GradientColourSet / Wave / Pulse) is parsed but rendered
+ statically — a later cycle will port the full animation
+ - Sidebar width is now configurable in Theme & Layout (range
+ 44–160 px). Default stays icon-only; widen to fit section
+ headers like 'Active Tells (3)' without truncation
+ - Settings Save no longer pops the chat input back to /tell with
+ a pinned partner — Configuration.UpdateFrom now preserves the
+ runtime CurrentChannel across the persistent-tab merge, and
+ TabSwitched deep-clones the seeded channel instead of sharing
+ the previous tab's UsedChannel
+ - Util/ImGuiUtil.cs DrawArrows IconButton id now uses
+ (id + 1).ToString() instead of the operator-precedence quirk
+ id + 1.ToString() — generated IDs stay numerically stable
+ - Internal: IPluginLogProxy indirection over Dalamud's IPluginLog
+ routes all ~91 Plugin.Log call sites through a testable proxy.
+ MessageStore.Migrate0 can now run in xUnit without loading
+ Dalamud.dll, closing the gap F12.1 left in v1.4.6
+ - Internal: TempTab counter switched from an Interlocked cached
+ field to a derived Tabs.Count(predicate) — pin-state transitions
+ are cold-path and don't need lock-free reads
+
+ Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
+
+ ---
+
**v1.4.6 — Code Hygiene and Refactor (2026-05-12)**
Maintenance patch. No user-visible behaviour changes; tightens the
@@ -117,19 +160,4 @@ changelog: |-
---
- **v1.4.3 — Faster plugin load + new repo (2026-05-08)**
-
- Heavy startup work (migrations, hooks, windows) now runs async so
- Dalamud's UI stays responsive during load. Load time is comparable
- to v1.4.2 — this is the foundation for v1.4.4 optimisations.
-
- - Two-phase async load via IAsyncDalamudPlugin
- - Schema-gate replaces the v9→v16 migration chain; old configs
- require a v1.4.2 install first
- - AutoTranslate cache loads on first use instead of every startup
- - Custom font (Hellion-Exo2) appears with a brief pop after load
- - Repo moved to gitea.hellion-forge.cloud — update your custom-repo URL
-
- ---
-
Full history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases
diff --git a/HellionChat/Integrations/HonorificTitleData.cs b/HellionChat/Integrations/HonorificTitleData.cs
index 5363851..c11b274 100644
--- a/HellionChat/Integrations/HonorificTitleData.cs
+++ b/HellionChat/Integrations/HonorificTitleData.cs
@@ -4,10 +4,19 @@ namespace HellionChat.Integrations;
// Local DTO mirroring Honorific's TitleData — no hard reference to Honorific.dll
// so HellionChat loads cleanly when Honorific is absent.
-// Glow/gradient fields omitted; Cycle 1 renders primary Color only.
+//
+// v1.4.7: render Glow only. Gradient (Color3 / GradientColourSet / Style) is
+// parsed and stashed so a future cycle can render it without re-shaping the
+// JSON roundtrip — see vault anchor "Honorific Full Gradient Port" (would
+// need GradientSystem.cs + the hardcoded Pride-palette list ported, or an
+// upstream IPC PR exposing the resolved frame colour).
internal sealed record HonorificTitleData(
string? Title,
bool IsPrefix,
bool IsOriginal,
- Vector3? Color
+ Vector3? Color,
+ Vector3? Glow,
+ Vector3? Color3,
+ int? GradientColourSet,
+ string? GradientAnimationStyle
);
diff --git a/HellionChat/Ipc/ExtraChat.cs b/HellionChat/Ipc/ExtraChat.cs
index 4258d54..f8a668c 100644
--- a/HellionChat/Ipc/ExtraChat.cs
+++ b/HellionChat/Ipc/ExtraChat.cs
@@ -62,7 +62,10 @@ public sealed class ExtraChat : IDisposable
catch (Exception ex)
{
// ExtraChat is optional; IPC failure is normal when the plugin isn't loaded.
- Plugin.Log.Verbose(ex, "ExtraChat IPC initial state query failed (peer not loaded?)");
+ Plugin.LogProxy.Verbose(
+ ex,
+ "ExtraChat IPC initial state query failed (peer not loaded?)"
+ );
}
}
diff --git a/HellionChat/Message.cs b/HellionChat/Message.cs
index 3453f03..154ba93 100755
--- a/HellionChat/Message.cs
+++ b/HellionChat/Message.cs
@@ -153,8 +153,8 @@ public partial class Message
}
catch (ArgumentException ex)
{
- Plugin.Log.Error(ex, "Failed to parse extra chat channel GUID");
- Plugin.Log.Error($"Byte Array: ${string.Join(", ", data[4..^1])}");
+ Plugin.LogProxy.Error(ex, "Failed to parse extra chat channel GUID");
+ Plugin.LogProxy.Error($"Byte Array: ${string.Join(", ", data[4..^1])}");
return Guid.Empty;
}
}
@@ -251,7 +251,7 @@ public partial class Message
AddChunkWithMessage(
text.NewWithStyle(chunk.Source, chunk.Link, token.Value)
);
- Plugin.Log.Debug(
+ Plugin.LogProxy.Debug(
$"Invalid URL accepted by Regex but failed URI parsing: '{token.Value}'"
);
}
@@ -416,7 +416,7 @@ public partial class Message
catch (Exception)
{
AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, split));
- Plugin.Log.Debug($"Failed to parse the text param: '{split}'");
+ Plugin.LogProxy.Debug($"Failed to parse the text param: '{split}'");
}
}
}
diff --git a/HellionChat/MessageManager.cs b/HellionChat/MessageManager.cs
index d50000d..99a9bf1 100644
--- a/HellionChat/MessageManager.cs
+++ b/HellionChat/MessageManager.cs
@@ -52,7 +52,7 @@ internal class MessageManager : IAsyncDisposable
{
Plugin = plugin;
- Store = new MessageStore(DatabasePath(), Plugin.PlatformUtil);
+ Store = new MessageStore(DatabasePath(), Plugin.PlatformUtil, Plugin.LogProxy);
PendingMessageThread = new Thread(() =>
ProcessPendingMessages(PendingThreadCancellationToken.Token)
@@ -91,7 +91,7 @@ internal class MessageManager : IAsyncDisposable
await Task.Delay(100);
if (PendingMessageThread.IsAlive)
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
"PendingMessageThread did not observe cancellation within 10s. "
+ "Worker remains on background thread; next plugin reload releases it."
);
@@ -137,7 +137,7 @@ internal class MessageManager : IAsyncDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error processing pending message");
+ Plugin.LogProxy.Error(ex, "Error processing pending message");
}
}
else
@@ -182,10 +182,12 @@ internal class MessageManager : IAsyncDisposable
// Mark failed messages as deleted to prevent retry attempts
var failedIds = messages.FailedMessageIds();
- Plugin.Log.Info($"Marking {failedIds.Count} messages as deleted due to parse failures");
+ Plugin.LogProxy.Info(
+ $"Marking {failedIds.Count} messages as deleted due to parse failures"
+ );
foreach (var msgId in messages.FailedMessageIds())
{
- Plugin.Log.Debug($"Marking message '{msgId}' as deleted due to parse failure");
+ Plugin.LogProxy.Debug($"Marking message '{msgId}' as deleted due to parse failure");
Store.DeleteMessage(msgId);
}
}
@@ -201,10 +203,10 @@ internal class MessageManager : IAsyncDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error in FilterAllTabs");
+ Plugin.LogProxy.Error(ex, "Error in FilterAllTabs");
}
- Plugin.Log.Debug($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms");
+ Plugin.LogProxy.Debug($"FilterAllTabs took {stopwatch.ElapsedMilliseconds}ms");
});
}
@@ -259,7 +261,7 @@ internal class MessageManager : IAsyncDisposable
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error in ContentIdResolver");
+ Plugin.LogProxy.Error(ex, "Error in ContentIdResolver");
}
}
diff --git a/HellionChat/MessageStore.cs b/HellionChat/MessageStore.cs
index b200c5f..8a23567 100644
--- a/HellionChat/MessageStore.cs
+++ b/HellionChat/MessageStore.cs
@@ -137,11 +137,13 @@ internal class MessageStore : IDisposable
);
private readonly IPlatformUtil _platformUtil;
+ private readonly IPluginLogProxy _logger;
- internal MessageStore(string dbPath, IPlatformUtil platformUtil)
+ internal MessageStore(string dbPath, IPlatformUtil platformUtil, IPluginLogProxy logger)
{
DbPath = dbPath;
_platformUtil = platformUtil;
+ _logger = logger;
Connection = Connect();
Migrate();
}
@@ -204,7 +206,7 @@ internal class MessageStore : IDisposable
private void Migrate0()
{
- Plugin.Log.Information("Running migration 0: Creating tables");
+ _logger.Information("Running migration 0: Creating tables");
Connection.Execute(
@"
CREATE TABLE IF NOT EXISTS messages (
@@ -231,7 +233,7 @@ internal class MessageStore : IDisposable
private void Migrate1()
{
- Plugin.Log.Information("Running migration 1: Adding Deleted column");
+ _logger.Information("Running migration 1: Adding Deleted column");
Connection.Execute(
@"
ALTER TABLE messages ADD COLUMN Deleted BOOLEAN NOT NULL DEFAULT false;
@@ -243,7 +245,7 @@ internal class MessageStore : IDisposable
private void Migrate2()
{
- Plugin.Log.Information("Running migration 2: Adding Channel generated column");
+ _logger.Information("Running migration 2: Adding Channel generated column");
Connection.Execute(
@"
ALTER TABLE messages ADD COLUMN Channel INTEGER GENERATED ALWAYS AS (Code & 0x7f) VIRTUAL;
@@ -271,15 +273,13 @@ internal class MessageStore : IDisposable
private void Migrate3()
{
- Plugin.Log.Information("Running migration 3: Fix log kinds to fit the new format");
+ _logger.Information("Running migration 3: Fix log kinds to fit the new format");
// Recovery for partially-applied Migrate3: schema already in target
// shape but user_version was never bumped -- just record and exit.
if (ColumnExists("messages", "ChatType") && !ColumnExists("messages", "Code"))
{
- Plugin.Log.Information(
- "Migration 3: schema already migrated, only bumping user_version"
- );
+ _logger.Information("Migration 3: schema already migrated, only bumping user_version");
SetMigrationVersion(3);
return;
}
@@ -309,7 +309,7 @@ internal class MessageStore : IDisposable
private void SetMigrationVersion(int version)
{
- Plugin.Log.Information($"Setting version {version}");
+ _logger.Information($"Setting version {version}");
using var cmd = Connection.CreateCommand();
// PRAGMA does not accept SQLite parameter bindings; version is a
// compile-time int from the migration sequence, never user input.
@@ -461,7 +461,7 @@ internal class MessageStore : IDisposable
// Privacy filter -- drop disallowed ChatTypes before they reach storage.
if (!Plugin.Config.IsAllowedForStorage(message.Code.Type))
{
- Plugin.Log.Verbose($"Privacy filter dropped message: ChatType={message.Code.Type}");
+ _logger.Verbose($"Privacy filter dropped message: ChatType={message.Code.Type}");
return;
}
@@ -554,7 +554,7 @@ internal class MessageStore : IDisposable
if (to is not null)
cmd.Parameters.AddWithValue("$To", to.Value.ToUnixTimeMilliseconds());
- return new MessageEnumerator(cmd.ExecuteReader());
+ return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
// Returns the most recent messages, oldest-first.
@@ -602,7 +602,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$Count", count);
- return new MessageEnumerator(cmd.ExecuteReader());
+ return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
// Returns up to limit tells exchanged with the named player, oldest-first.
@@ -640,7 +640,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$ScanLimit", sqlScanLimit);
var collected = new List();
- using var enumerator = new MessageEnumerator(cmd.ExecuteReader());
+ using var enumerator = new MessageEnumerator(cmd.ExecuteReader(), _logger);
foreach (var message in enumerator)
{
if (!ChunkUtil.MatchesSender(message, senderName, senderWorld))
@@ -732,7 +732,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$After", ((DateTimeOffset)after).ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$Before", ((DateTimeOffset)before).ToUnixTimeMilliseconds());
- return new MessageEnumerator(cmd.ExecuteReader());
+ return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
internal MessageEnumerator GetPagedDateRange(
@@ -776,7 +776,7 @@ internal class MessageStore : IDisposable
cmd.Parameters.AddWithValue("$Offset", DbViewer.RowPerPage * page);
cmd.Parameters.AddWithValue("$OffsetCount", DbViewer.RowPerPage);
- return new MessageEnumerator(cmd.ExecuteReader());
+ return new MessageEnumerator(cmd.ExecuteReader(), _logger);
}
// Builds a "$prefix0,$prefix1,..." placeholder list and binds values to the command.
@@ -796,13 +796,14 @@ internal class MessageStore : IDisposable
}
}
-internal class MessageEnumerator(DbDataReader reader)
+internal class MessageEnumerator(DbDataReader reader, IPluginLogProxy logger)
: IEnumerable,
IDisposable,
IAsyncDisposable
{
private const int MaxErrorLogs = 10;
+ private readonly IPluginLogProxy _logger = logger;
private readonly List FailedIds = [];
private int FailedCount;
public bool DidError => FailedCount > 0;
@@ -848,10 +849,10 @@ internal class MessageEnumerator(DbDataReader reader)
catch (Exception e)
{
if (FailedCount < MaxErrorLogs)
- Plugin.Log.Error($"Exception while reading message '{id}' from database: {e}");
+ _logger.Error($"Exception while reading message '{id}' from database: {e}");
FailedCount++;
if (FailedCount == MaxErrorLogs)
- Plugin.Log.Error("Further parsing errors will not be logged");
+ _logger.Error("Further parsing errors will not be logged");
if (id != Guid.Empty)
FailedIds.Add(id);
diff --git a/HellionChat/PayloadHandler.cs b/HellionChat/PayloadHandler.cs
index 0bd4c56..6dac11a 100755
--- a/HellionChat/PayloadHandler.cs
+++ b/HellionChat/PayloadHandler.cs
@@ -131,7 +131,7 @@ public sealed class PayloadHandler
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error executing integration");
+ Plugin.LogProxy.Error(ex, "Error executing integration");
}
}
@@ -535,7 +535,7 @@ public sealed class PayloadHandler
)
)
{
- Plugin.Log.Warning("Could not find DalamudLinkHandlers");
+ Plugin.LogProxy.Warning("Could not find DalamudLinkHandlers");
return;
}
@@ -546,7 +546,7 @@ public sealed class PayloadHandler
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error executing DalamudLinkPayload handler");
+ Plugin.LogProxy.Error(ex, "Error executing DalamudLinkPayload handler");
}
}
diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs
index 2863e82..ea1594b 100755
--- a/HellionChat/Plugin.cs
+++ b/HellionChat/Plugin.cs
@@ -117,6 +117,11 @@ public sealed class Plugin : IAsyncDalamudPlugin
// any service allocated in LoadAsync can read Plugin.PlatformUtil.
internal static IPlatformUtil PlatformUtil { get; private set; } = null!;
+ // Log indirection over Dalamud's IPluginLog. Same rationale as PlatformUtil:
+ // call-sites read through LogProxy so MessageStore can be tested in
+ // isolation. Wired immediately after Dalamud injects Log.
+ internal static IPluginLogProxy LogProxy { get; private set; } = null!;
+
// Idempotency guard — Dalamud may fire DisposeAsync twice in a reload race.
private int _disposeStarted;
@@ -162,21 +167,24 @@ public sealed class Plugin : IAsyncDalamudPlugin
// needs Util.* — services then read Plugin.PlatformUtil instead of
// hitting the Dalamud static surface directly.
PlatformUtil = new DalamudPlatformUtil();
+ LogProxy = new DalamudPluginLogProxy(Log);
- // Schema gate: v1.4.x requires config v16. Users on older schemas
- // must install v1.4.2 first to run the migration chain.
+ // Schema gate: v1.4.x requires config v16+. Users on older schemas
+ // must install v1.4.2 first to run the migration chain. v17 adds
+ // Tab.IsPinned (additive, no data migration needed) so v16 configs
+ // load cleanly and get their Version stamp bumped after the gate.
if (Config.Version < 16)
{
throw new InvalidOperationException(
- $"HellionChat v1.4.6 requires config schema v16, got v{Config.Version}. "
- + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.6."
+ $"HellionChat v1.4.7 requires config schema v16, got v{Config.Version}. "
+ + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.7."
);
}
+ Config.Version = 17;
- // Session-only tabs are stripped on every load; AutoTellTabsService.Initialize
- // then re-pegs TempTabCounter from the stripped list, not the pre-strip snapshot.
- // TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs
- Config.Tabs.RemoveAll(t => t.IsTempTab);
+ // Unpinned TempTabs are session-only and dropped on every load. Pinned
+ // TempTabs survive reload — Jin's tester feedback (v1.4.7).
+ Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad);
LanguageChanged(Interface.UiLanguage);
ImGuiUtil.Initialize(this);
@@ -646,21 +654,17 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal void SaveConfig()
{
- // Session-only Auto-Tell-Tabs aren't persisted, so they move aside
- // before serialization and re-attach after. Cloning only the temp
- // subset keeps the allocation proportional to AutoTellTabsLimit
- // (<=15) instead of the full tab list.
- var tempTabs = Config.Tabs.Where(t => t.IsTempTab).ToList();
- Config.Tabs.RemoveAll(t => t.IsTempTab);
+ // Only unpinned TempTabs are session-only — they move aside before
+ // serialization and re-attach after. Pinned TempTabs stay in
+ // Config.Tabs across the save so JSON includes them. Cloning only the
+ // unpinned subset keeps the allocation proportional to
+ // AutoTellTabsLimit (<=15) instead of the full tab list.
+ var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
+ Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
Interface.SavePluginConfig(Config);
- Config.Tabs.AddRange(tempTabs);
-
- // F2.1: the mid-step RemoveAll bypasses AutoTellTabsService, so
- // re-peg the counter. Null-conditional because SaveConfig can fire
- // before Phase-2 init.
- AutoTellTabsService?.ResyncTempTabCounter();
+ Config.Tabs.AddRange(unpinnedTempTabs);
}
internal void LanguageChanged(string langCode)
diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs
index 4ac3105..2aaddc8 100644
--- a/HellionChat/Resources/HellionStrings.Designer.cs
+++ b/HellionChat/Resources/HellionStrings.Designer.cs
@@ -170,6 +170,16 @@ internal class HellionStrings
internal static string AutoTellTabs_HistoryLoadError => Get(nameof(AutoTellTabs_HistoryLoadError));
internal static string AutoTellTabs_GreetedTooltip => Get(nameof(AutoTellTabs_GreetedTooltip));
internal static string AutoTellTabs_UnGreetedTooltip => Get(nameof(AutoTellTabs_UnGreetedTooltip));
+ internal static string PinTab_MenuPin => Get(nameof(PinTab_MenuPin));
+ internal static string PinTab_MenuUnpin => Get(nameof(PinTab_MenuUnpin));
+ internal static string PinTab_MenuPromote => Get(nameof(PinTab_MenuPromote));
+ internal static string PinTab_PromoteTooltip => Get(nameof(PinTab_PromoteTooltip));
+ internal static string PinTab_LimitReached => Get(nameof(PinTab_LimitReached));
+ internal static string PinTab_PinnedTooltip => Get(nameof(PinTab_PinnedTooltip));
+ internal static string PinTab_PinTooltip => Get(nameof(PinTab_PinTooltip));
+ internal static string PinTab_SectionHeader => Get(nameof(PinTab_SectionHeader));
+ internal static string Settings_ThemeAndLayout_SidebarWidth_Name => Get(nameof(Settings_ThemeAndLayout_SidebarWidth_Name));
+ internal static string Settings_ThemeAndLayout_SidebarWidth_Description => Get(nameof(Settings_ThemeAndLayout_SidebarWidth_Description));
// Hellion Chat — Auto-Tell-Tabs Chat settings tab
internal static string ChatLog_AutoTellTabs_Section_Title => Get(nameof(ChatLog_AutoTellTabs_Section_Title));
@@ -370,6 +380,8 @@ internal class HellionStrings
internal static string Settings_Integrations_Honorific_Status_Incompatible => Get(nameof(Settings_Integrations_Honorific_Status_Incompatible));
internal static string Settings_Integrations_Honorific_Toggle => Get(nameof(Settings_Integrations_Honorific_Toggle));
internal static string Settings_Integrations_Honorific_ToggleHint => Get(nameof(Settings_Integrations_Honorific_ToggleHint));
+ internal static string Settings_Integrations_Honorific_Glow_Toggle => Get(nameof(Settings_Integrations_Honorific_Glow_Toggle));
+ internal static string Settings_Integrations_Honorific_Glow_Hint => Get(nameof(Settings_Integrations_Honorific_Glow_Hint));
internal static string Settings_Integrations_Honorific_LinkRepo => Get(nameof(Settings_Integrations_Honorific_LinkRepo));
internal static string Settings_Integrations_Honorific_LinkAuthor => Get(nameof(Settings_Integrations_Honorific_LinkAuthor));
internal static string Settings_Integrations_ComingSoon_SectionHeader => Get(nameof(Settings_Integrations_ComingSoon_SectionHeader));
diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx
index 687d439..e9fc2ff 100644
--- a/HellionChat/Resources/HellionStrings.de.resx
+++ b/HellionChat/Resources/HellionStrings.de.resx
@@ -383,6 +383,36 @@
Als begrüßt markieren.
+
+ Tab anpinnen
+
+
+ Tab lösen
+
+
+ In Standard-Tab umwandeln
+
+
+ Wandelt den TempTell in einen regulären Tab um. Die Tell-Bindung an die Person geht verloren, der Tab fängt dann Nachrichten anhand der Channel-Filter ein. Für „Tab überlebt Relog" stattdessen „Tab anpinnen" wählen.
+
+
+ Maximal {0} angepinnte Tell-Tabs erreicht. Erst einen lösen oder dauerhaft behalten.
+
+
+ Angepinnt — überlebt Relog.
+
+
+ Angepinnte Tabs überleben Relog und behalten die Bindung an die Tell-Person.
+
+
+ Angepinnt
+
+
+ Sidebar-Breite
+
+
+ Breite der Tab-Sidebar in Pixeln. Default (44 px) ist Icon-only; breiter machen damit Sektion-Header wie „Aktive Tells (3)" nicht abgeschnitten werden.
+
@@ -398,7 +428,7 @@
Maximale Anzahl der Auto-Tell-Tabs
- Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell.
+ Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell. Diese Grenze gilt nur für den automatisch verwalteten Pool. Angepinnte Tell-Tabs (Rechtsklick → Tab anpinnen) leben in einem separaten Pool von bis zu 5 Tabs und überleben Relog.
Kompakte Anzeige
@@ -827,6 +857,12 @@
Zeigt deinen Custom-Titel aus Honorific im Header über dem Chat-Log an, in der von dir gewählten Farbe.
+
+ Glow-Outline rendern (Honorific)
+
+
+ Kann die Framerate auf schwacher Hardware drücken. Rendert die Glow-Outline für Honorific-Titel, die sie nutzen. Gradient-Animation wird noch nicht unterstützt und wird stattdessen als Primärfarbe gezeichnet.
+
Honorific auf GitHub
diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx
index 0c736ea..8940f46 100644
--- a/HellionChat/Resources/HellionStrings.resx
+++ b/HellionChat/Resources/HellionStrings.resx
@@ -383,6 +383,36 @@
Mark as greeted.
+
+ Pin Tab
+
+
+ Unpin Tab
+
+
+ Promote to permanent
+
+
+ Turns this TempTell into a regular tab. The tell binding to the partner is dropped — the tab will catch messages by its channel filters from now on. For "tab survives relog while staying bound to this partner", use Pin Tab instead.
+
+
+ Pinned tabs survive relog and stay bound to this conversation partner.
+
+
+ Pinned
+
+
+ Sidebar width
+
+
+ Width of the tab sidebar in pixels. The default (44 px) is icon-only; widen it to fit the section headers like "Active Tells (3)" without truncation.
+
+
+ Maximum of {0} pinned tell tabs reached. Unpin one first, or use Promote to permanent.
+
+
+ Pinned — survives relog.
+
@@ -398,7 +428,7 @@
Maximum number of auto-tell tabs
- When the limit is reached, greeted tabs with the oldest activity are closed first. Changes take effect on the next /tell.
+ When the limit is reached, greeted tabs with the oldest activity are closed first. Changes take effect on the next /tell. This limit applies to the auto-managed pool. Pinned tell tabs (right-click → Pin Tab) live in a separate pool of up to 5 and survive relog.
Compact display
@@ -827,6 +857,12 @@
Shows your custom title from Honorific in the header above the chat log, in the colour you have chosen.
+
+ Render glow outlines (Honorific)
+
+
+ May reduce frame rate on low-end hardware. Renders glow outlines for Honorific titles that use them. Gradient animation is not yet supported and will render as the primary colour.
+
Honorific on GitHub
diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs
index 53b4610..90f0135 100644
--- a/HellionChat/Themes/ThemeRegistry.cs
+++ b/HellionChat/Themes/ThemeRegistry.cs
@@ -118,7 +118,7 @@ public sealed class ThemeRegistry
catch (Exception ex) when (IsRecoverableFileLock(ex))
{
// Editor mid-save: keep last known good, retry on next refresh.
- Plugin.Log.Debug(
+ Plugin.LogProxy.Debug(
$"Custom theme {Path.GetFileName(path)} is locked, keeping last known good"
);
if (cached.Theme is not null)
diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs
index 0f43eda..3d2a502 100644
--- a/HellionChat/Ui/ChatLogWindow.cs
+++ b/HellionChat/Ui/ChatLogWindow.cs
@@ -277,7 +277,7 @@ public sealed class ChatLogWindow : Window
|| !GameFunctions.Chat.IsChannelOrExistingLinkshell(targetChannel.Value)
)
{
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
$"Channel was set to an invalid value '{targetChannel}', ignoring"
);
return;
@@ -331,11 +331,11 @@ public sealed class ChatLogWindow : Window
{
case "hide":
CurrentHideState = HideState.User;
- Plugin.Log.Verbose("HideState: → User (chat hide command)");
+ Plugin.LogProxy.Verbose("HideState: → User (chat hide command)");
break;
case "show":
CurrentHideState = HideState.None;
- Plugin.Log.Verbose("HideState: → None (chat show command)");
+ Plugin.LogProxy.Verbose("HideState: → None (chat show command)");
break;
case "toggle":
CurrentHideState = CurrentHideState switch
@@ -345,7 +345,7 @@ public sealed class ChatLogWindow : Window
HideState.None => HideState.User,
_ => CurrentHideState,
};
- Plugin.Log.Verbose($"HideState: → {CurrentHideState} (chat toggle command)");
+ Plugin.LogProxy.Verbose($"HideState: → {CurrentHideState} (chat toggle command)");
break;
}
}
@@ -441,11 +441,24 @@ public sealed class ChatLogWindow : Window
private void TabSwitched(Tab newTab, Tab previousTab)
{
- // Use the fixed channel if set by the user, or set it to the current tabs channel if this tab wasn't accessed before
+ // Use the fixed channel if set by the user. Otherwise, if the new tab
+ // has no channel state yet (fresh from JSON, never selected this
+ // session), seed from the previous tab — but deep-clone so we don't
+ // share TellTarget with the previous tab. Without the clone, a later
+ // /tell on the new tab would mutate the pinned tab's TellTarget and
+ // the Party/Linkshell channel would pop back to the pinned tell-mark.
if (newTab.Channel is not null)
+ {
newTab.CurrentChannel.Channel = newTab.Channel.Value;
+ }
else if (newTab.CurrentChannel.Channel is InputChannel.Invalid)
- newTab.CurrentChannel = previousTab.CurrentChannel;
+ {
+ newTab.CurrentChannel = previousTab.CurrentChannel.Clone();
+ Plugin.LogProxy.Debug(
+ $"[Tab] '{newTab.Name}' seeded channel from '{previousTab.Name}' "
+ + $"(Channel={newTab.CurrentChannel.Channel}, TellTarget={newTab.CurrentChannel.TellTarget?.ToTargetString() ?? "null"})"
+ );
+ }
SetChannel(newTab.CurrentChannel.Channel);
}
@@ -469,14 +482,14 @@ public sealed class ChatLogWindow : Window
if (Plugin.Config.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
{
CurrentHideState = HideState.Battle;
- Plugin.Log.Verbose("HideState: None → Battle");
+ Plugin.LogProxy.Verbose("HideState: None → Battle");
}
// If the chat is hidden because of battle, we reset it here
if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
{
CurrentHideState = HideState.None;
- Plugin.Log.Verbose("HideState: Battle → None");
+ Plugin.LogProxy.Verbose("HideState: Battle → None");
}
// if the chat has no hide state and in a cutscene, set the hide state to cutscene
@@ -489,7 +502,7 @@ public sealed class ChatLogWindow : Window
if (Plugin.Functions.Chat.CheckHideFlags())
{
CurrentHideState = HideState.Cutscene;
- Plugin.Log.Verbose("HideState: None → Cutscene");
+ Plugin.LogProxy.Verbose("HideState: None → Cutscene");
}
}
@@ -500,7 +513,7 @@ public sealed class ChatLogWindow : Window
&& !Plugin.GposeActive
)
{
- Plugin.Log.Verbose($"HideState: {CurrentHideState} → None (cutscene/gpose ended)");
+ Plugin.LogProxy.Verbose($"HideState: {CurrentHideState} → None (cutscene/gpose ended)");
CurrentHideState = HideState.None;
}
@@ -508,14 +521,14 @@ public sealed class ChatLogWindow : Window
if (CurrentHideState == HideState.Cutscene && Activate)
{
CurrentHideState = HideState.CutsceneOverride;
- Plugin.Log.Verbose("HideState: Cutscene → CutsceneOverride (user activate)");
+ Plugin.LogProxy.Verbose("HideState: Cutscene → CutsceneOverride (user activate)");
}
// if the user hid the chat and is now activating chat, reset the hide state
if (CurrentHideState == HideState.User && Activate)
{
CurrentHideState = HideState.None;
- Plugin.Log.Verbose("HideState: User → None (activate)");
+ Plugin.LogProxy.Verbose("HideState: User → None (activate)");
}
if (
@@ -633,7 +646,7 @@ public sealed class ChatLogWindow : Window
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Error drawing Chat Log window");
+ Plugin.LogProxy.Error(ex, "Error drawing Chat Log window");
if (!NotifiedDrawFailure)
{
Plugin.Notification.AddNotification(
@@ -1608,7 +1621,7 @@ public sealed class ChatLogWindow : Window
}
catch (Exception ex)
{
- Plugin.Log.Warning(ex, "Error drawing chat log");
+ Plugin.LogProxy.Warning(ex, "Error drawing chat log");
}
}
@@ -1673,6 +1686,30 @@ public sealed class ChatLogWindow : Window
Plugin.WantedTab = null;
}
+ // Sidebar render order: persistent tabs in their original Plugin.Config.Tabs
+ // position, then pinned TempTabs, then unpinned TempTabs. Returns indices
+ // into Plugin.Config.Tabs so tabI in the loop body still mirrors the real
+ // list position (LastTab / WantedTab stay consistent).
+ private static List BuildSidebarRenderOrder()
+ {
+ var tabs = Plugin.Config.Tabs;
+ var persistent = new List(tabs.Count);
+ var pinned = new List();
+ var unpinned = new List();
+ for (var i = 0; i < tabs.Count; i++)
+ {
+ if (TabLifecycleHelpers.IsInPinnedPool(tabs[i]))
+ pinned.Add(i);
+ else if (TabLifecycleHelpers.IsInUnpinnedPool(tabs[i]))
+ unpinned.Add(i);
+ else
+ persistent.Add(i);
+ }
+ persistent.AddRange(pinned);
+ persistent.AddRange(unpinned);
+ return persistent;
+ }
+
private void DrawTabSidebar()
{
var currentTab = -1;
@@ -1685,7 +1722,8 @@ public sealed class ChatLogWindow : Window
if (!tabTable.Success)
return;
- ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, 44f);
+ var sidebarWidth = Math.Clamp(Plugin.Config.SidebarWidth, 44, 160);
+ ImGui.TableSetupColumn("tabs", ImGuiTableColumnFlags.WidthFixed, sidebarWidth);
ImGui.TableSetupColumn("chat", ImGuiTableColumnFlags.WidthStretch, 1);
ImGui.TableNextColumn();
@@ -1704,23 +1742,42 @@ public sealed class ChatLogWindow : Window
ImGui.Dummy(new Vector2(0, ImGui.GetFrameHeightWithSpacing()));
var previousTab = Plugin.CurrentTab;
- // Divider rendered once before the first temp tab with a live unit counter.
+ // Render order: persistent → pinned TempTabs → unpinned TempTabs.
+ // Underlying Plugin.Config.Tabs order is untouched (tabI mirrors
+ // the real list index), only the display sequence groups by
+ // section so each section can carry its own divider header.
+ var renderOrder = BuildSidebarRenderOrder();
+ var pinnedHeaderRendered = false;
var tempTabHeaderRendered = false;
- var tempTabCount = Plugin.Config.Tabs.Count(t => t.IsTempTab);
+ var pinnedCount = Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
+ var unpinnedTempCount = Plugin.Config.Tabs.Count(
+ TabLifecycleHelpers.IsInUnpinnedPool
+ );
- for (var tabI = 0; tabI < Plugin.Config.Tabs.Count; tabI++)
+ foreach (var tabI in renderOrder)
{
var tab = Plugin.Config.Tabs[tabI];
if (tab.PopOut)
continue;
- if (tab.IsTempTab && !tempTabHeaderRendered)
+ if (TabLifecycleHelpers.IsInPinnedPool(tab) && !pinnedHeaderRendered)
{
ImGui.Separator();
if (!Plugin.Config.AutoTellTabsCompactDisplay)
{
ImGui.TextDisabled(
- $"{HellionStrings.AutoTellTabs_SectionHeader} ({tempTabCount})"
+ $"{HellionStrings.PinTab_SectionHeader} ({pinnedCount})"
+ );
+ }
+ pinnedHeaderRendered = true;
+ }
+ else if (TabLifecycleHelpers.IsInUnpinnedPool(tab) && !tempTabHeaderRendered)
+ {
+ ImGui.Separator();
+ if (!Plugin.Config.AutoTellTabsCompactDisplay)
+ {
+ ImGui.TextDisabled(
+ $"{HellionStrings.AutoTellTabs_SectionHeader} ({unpinnedTempCount})"
);
}
tempTabHeaderRendered = true;
@@ -1809,9 +1866,12 @@ public sealed class ChatLogWindow : Window
using (ImRaii.PushColor(ImGuiCol.Text, ColourUtil.RgbaToAbgr(iconColor)))
using (Plugin.FontManager.FontAwesome.Push())
{
+ // Button stretches with the configured sidebar width so a
+ // user-widened sidebar feels intentional, not a 36px icon
+ // floating in empty space.
clicked = ImGui.Button(
$"{icon.ToIconString()}##sidebar-tab-{tabI}",
- new Vector2(36f, ImGui.GetFrameHeight())
+ new Vector2(sidebarWidth - 8f, ImGui.GetFrameHeight())
);
}
@@ -1871,11 +1931,35 @@ public sealed class ChatLogWindow : Window
);
}
+ // Pin indicator: subtle thumbtack glyph top-left of the icon.
+ // Muted colour because the "Pinned" section header already
+ // groups these tabs visually — this is just a per-tab
+ // confirmation glyph, not the primary discoverability cue.
+ if (tab.IsPinned)
+ {
+ var min = ImGui.GetItemRectMin();
+ const float pinPadding = 1f;
+ var pinPos = new Vector2(min.X + pinPadding, min.Y + pinPadding);
+ var pinColor = theme.Colors.TextMuted;
+ // Dim further so the glyph reads as a hint, not a badge.
+ var pinAbgr = ColourUtil.RgbaToAbgr(pinColor) & 0x77FFFFFFu;
+ using (Plugin.FontManager.FontAwesome.Push())
+ {
+ ImGui
+ .GetWindowDrawList()
+ .AddText(pinPos, pinAbgr, FontAwesomeIcon.Thumbtack.ToIconString());
+ }
+ }
+
// Tooltip mit Tab-Name + Unread-Counter beim Hover.
if (ImGui.IsItemHovered())
{
using var tt = ImRaii.Tooltip();
ImGui.TextUnformatted($"{tab.Name}{unread}");
+ if (tab.IsPinned)
+ {
+ ImGui.TextUnformatted(HellionStrings.PinTab_PinnedTooltip);
+ }
}
DrawTabContextMenu(tab, tabI);
@@ -1999,10 +2083,7 @@ public sealed class ChatLogWindow : Window
ImGui.TextUnformatted(FontAwesomeIcon.Crown.ToIconString());
}
ImGui.SameLine(0f, gapAfterCrown);
- using (ImRaii.PushColor(ImGuiCol.Text, titleColor))
- {
- ImGui.TextUnformatted(rendered);
- }
+ DrawHonorificTitleText(rendered, titleColor, title.Glow);
ImGui.EndGroup();
if (ImGui.IsItemHovered())
@@ -2013,6 +2094,35 @@ public sealed class ChatLogWindow : Window
ImGui.SameLine();
}
+ // Renders the title text, optionally with a glow outline pre-pass. Glow is
+ // drawn at 8 cardinal offsets (±1 px) in the glow colour at reduced alpha,
+ // then the primary text on top. The pre-pass uses the window draw list so
+ // it composites correctly with the regular ImGui text that follows.
+ private void DrawHonorificTitleText(string rendered, Vector4 titleColor, Vector3? glow)
+ {
+ if (Plugin.Config.ShowHonorificGlow && glow is { } g)
+ {
+ var pos = ImGui.GetCursorScreenPos();
+ var glowColor = new Vector4(g.X, g.Y, g.Z, 0.4f);
+ var glowAbgr = ImGui.ColorConvertFloat4ToU32(glowColor);
+ var drawList = ImGui.GetWindowDrawList();
+ for (var dy = -1; dy <= 1; dy++)
+ {
+ for (var dx = -1; dx <= 1; dx++)
+ {
+ if (dx == 0 && dy == 0)
+ continue;
+ drawList.AddText(new Vector2(pos.X + dx, pos.Y + dy), glowAbgr, rendered);
+ }
+ }
+ }
+
+ using (ImRaii.PushColor(ImGuiCol.Text, titleColor))
+ {
+ ImGui.TextUnformatted(rendered);
+ }
+ }
+
// One-time hint banner for the pop-out header button and right-click pathway.
private float DrawV061HintBannerIfNeeded()
{
@@ -2059,7 +2169,7 @@ public sealed class ChatLogWindow : Window
{
Plugin.Config.SeenPopOutHeaderHint = true;
Plugin.SaveConfig();
- Plugin.Log.Debug("v0.6.1 pop-out header hint dismissed");
+ Plugin.LogProxy.Debug("v0.6.1 pop-out header hint dismissed");
if (openSettings)
Plugin.SettingsWindow.Toggle();
}
@@ -2124,10 +2234,52 @@ public sealed class ChatLogWindow : Window
anyChanged = true;
}
+ if (tab.IsTempTab)
+ {
+ ImGui.Separator();
+ DrawPinControls(tab);
+ }
+
if (anyChanged)
Plugin.SaveConfig();
}
+ private void DrawPinControls(Tab tab)
+ {
+ var svc = Plugin.AutoTellTabsService;
+ if (svc == null)
+ return;
+
+ if (tab.IsPinned)
+ {
+ if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin))
+ {
+ svc.Unpin(tab);
+ ImGui.CloseCurrentPopup();
+ }
+ }
+ else
+ {
+ var atCap = svc.PinnedTempTabCount >= AutoTellTabsService.MaxPinnedTempTabs;
+ if (ImGui.MenuItem(HellionStrings.PinTab_MenuPin, enabled: !atCap))
+ {
+ if (svc.TryPin(tab))
+ ImGui.CloseCurrentPopup();
+ }
+ if (ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
+ {
+ ImGui.SetTooltip(
+ atCap
+ ? string.Format(
+ HellionStrings.PinTab_LimitReached,
+ AutoTellTabsService.MaxPinnedTempTabs
+ )
+ : HellionStrings.PinTab_PinTooltip
+ );
+ }
+ }
+ }
+
internal readonly List PopOutDocked = [];
internal readonly HashSet PopOutWindows = [];
@@ -2672,7 +2824,7 @@ public sealed class ChatLogWindow : Window
var viewport = ImGui.GetMainViewport();
var safePos = viewport.WorkPos + SafeDefaultOffset;
Position = safePos;
- Plugin.Log.Info(
+ Plugin.LogProxy.Info(
$"[Window-Recovery] {source}: snapping main window from {LastWindowPos} (size {LastWindowSize}) to {safePos}."
);
diff --git a/HellionChat/Ui/DbViewer.cs b/HellionChat/Ui/DbViewer.cs
index 58b2c3c..1326a29 100644
--- a/HellionChat/Ui/DbViewer.cs
+++ b/HellionChat/Ui/DbViewer.cs
@@ -307,7 +307,7 @@ public class DbViewer : Window
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Failed reading messages from database");
+ Plugin.LogProxy.Error(ex, "Failed reading messages from database");
}
finally
{
@@ -570,7 +570,7 @@ public class DbViewer : Window
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, "Failed creating txt backup");
+ Plugin.LogProxy.Error(ex, "Failed creating txt backup");
Notification.Content = "Error ...";
Notification.Type = NotificationType.Error;
diff --git a/HellionChat/Ui/Popout.cs b/HellionChat/Ui/Popout.cs
index fbbe483..3b6ddd6 100644
--- a/HellionChat/Ui/Popout.cs
+++ b/HellionChat/Ui/Popout.cs
@@ -175,7 +175,7 @@ internal class Popout : Window
{
Plugin.Config.SeenPopOutInputHint = true;
ChatLogWindow.Plugin.SaveConfig();
- Plugin.Log.Debug("Pop-Out input hint dismissed");
+ Plugin.LogProxy.Debug("Pop-Out input hint dismissed");
if (openSettings)
ChatLogWindow.Plugin.SettingsWindow.Toggle();
}
@@ -214,13 +214,13 @@ internal class Popout : Window
if (Tab.HideInBattle && CurrentHideState == HideState.None && Plugin.InBattle)
{
CurrentHideState = HideState.Battle;
- Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: None -> Battle");
+ Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: None -> Battle");
}
if (CurrentHideState is HideState.Battle && !Plugin.InBattle)
{
CurrentHideState = HideState.None;
- Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: Battle -> None");
+ Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: Battle -> None");
}
if (
@@ -232,7 +232,7 @@ internal class Popout : Window
if (ChatLogWindow.Plugin.Functions.Chat.CheckHideFlags())
{
CurrentHideState = HideState.Cutscene;
- Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: None -> Cutscene");
+ Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: None -> Cutscene");
}
}
@@ -242,7 +242,7 @@ internal class Popout : Window
&& !Plugin.GposeActive
)
{
- Plugin.Log.Verbose(
+ Plugin.LogProxy.Verbose(
$"Popout HideState [{Tab.Name}]: {CurrentHideState} -> None (cutscene/gpose ended)"
);
CurrentHideState = HideState.None;
@@ -251,7 +251,7 @@ internal class Popout : Window
if (CurrentHideState == HideState.Cutscene && ChatLogWindow.Activate)
{
CurrentHideState = HideState.CutsceneOverride;
- Plugin.Log.Verbose(
+ Plugin.LogProxy.Verbose(
$"Popout HideState [{Tab.Name}]: Cutscene -> CutsceneOverride (user activate)"
);
}
@@ -259,7 +259,7 @@ internal class Popout : Window
if (CurrentHideState == HideState.User && ChatLogWindow.Activate)
{
CurrentHideState = HideState.None;
- Plugin.Log.Verbose($"Popout HideState [{Tab.Name}]: User -> None (activate)");
+ Plugin.LogProxy.Verbose($"Popout HideState [{Tab.Name}]: User -> None (activate)");
}
return CurrentHideState is HideState.Cutscene or HideState.User or HideState.Battle
diff --git a/HellionChat/Ui/SettingsTabs/DataManagement.cs b/HellionChat/Ui/SettingsTabs/DataManagement.cs
index b63fe4b..bdcaef1 100644
--- a/HellionChat/Ui/SettingsTabs/DataManagement.cs
+++ b/HellionChat/Ui/SettingsTabs/DataManagement.cs
@@ -229,7 +229,7 @@ internal sealed class DataManagement : ISettingsTab
}
catch (Exception e)
{
- Plugin.Log.Error(e, "Unable to delete old database");
+ Plugin.LogProxy.Error(e, "Unable to delete old database");
WrapperUtil.AddNotification(
Language.Options_Database_Old_Delete_Error,
NotificationType.Error
@@ -391,7 +391,9 @@ internal sealed class DataManagement : ISettingsTab
Plugin.Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
Plugin.SaveConfig();
- Plugin.Log.Information($"Manual retention run deleted {deleted} expired messages.");
+ Plugin.LogProxy.Information(
+ $"Manual retention run deleted {deleted} expired messages."
+ );
if (deleted > 0)
{
@@ -405,7 +407,7 @@ internal sealed class DataManagement : ISettingsTab
.Wait(TimeSpan.FromSeconds(5))
)
{
- Plugin.Log.Warning(
+ Plugin.LogProxy.Warning(
"Retention sweep: framework refresh timed out after 5s."
);
}
@@ -418,7 +420,7 @@ internal sealed class DataManagement : ISettingsTab
}
catch (Exception e)
{
- Plugin.Log.Error(e, "Manual retention run failed");
+ Plugin.LogProxy.Error(e, "Manual retention run failed");
WrapperUtil.AddNotification(HellionStrings.Retention_Error, NotificationType.Error);
}
finally
@@ -566,7 +568,7 @@ internal sealed class DataManagement : ISettingsTab
}
catch (Exception e)
{
- Plugin.Log.Error(e, "Failed to compute cleanup preview");
+ Plugin.LogProxy.Error(e, "Failed to compute cleanup preview");
WrapperUtil.AddNotification(
HellionStrings.Cleanup_PreviewError,
NotificationType.Error
@@ -587,7 +589,7 @@ internal sealed class DataManagement : ISettingsTab
try
{
var deleted = Plugin.MessageManager.Store.CleanupRetainOnly(allowed);
- Plugin.Log.Information($"Privacy cleanup: deleted {deleted} messages");
+ Plugin.LogProxy.Information($"Privacy cleanup: deleted {deleted} messages");
if (
!Plugin
@@ -599,7 +601,9 @@ internal sealed class DataManagement : ISettingsTab
.Wait(TimeSpan.FromSeconds(5))
)
{
- Plugin.Log.Warning("Privacy cleanup: framework refresh timed out after 5s.");
+ Plugin.LogProxy.Warning(
+ "Privacy cleanup: framework refresh timed out after 5s."
+ );
}
WrapperUtil.AddNotification(
@@ -609,7 +613,7 @@ internal sealed class DataManagement : ISettingsTab
}
catch (Exception e)
{
- Plugin.Log.Error(e, "Privacy cleanup failed");
+ Plugin.LogProxy.Error(e, "Privacy cleanup failed");
WrapperUtil.AddNotification(HellionStrings.Cleanup_Error, NotificationType.Error);
}
finally
@@ -769,7 +773,7 @@ internal sealed class DataManagement : ISettingsTab
}
catch (Exception e)
{
- Plugin.Log.Error(e, "Export failed");
+ Plugin.LogProxy.Error(e, "Export failed");
WrapperUtil.AddNotification(HellionStrings.Export_Error, NotificationType.Error);
}
finally
@@ -849,7 +853,7 @@ internal sealed class DataManagement : ISettingsTab
)
)
{
- Plugin.Log.Warning("Clearing messages from database");
+ Plugin.LogProxy.Warning("Clearing messages from database");
Plugin.MessageManager.Store.ClearMessages();
Plugin.MessageManager.ClearAllTabs();
@@ -907,7 +911,7 @@ internal sealed class DataManagement : ISettingsTab
private void InsertMessages(int count)
{
- Plugin.Log.Info($"Inserting {count} messages due to user request");
+ Plugin.LogProxy.Info($"Inserting {count} messages due to user request");
var stopwatch = Stopwatch.StartNew();
var playerName = Plugin.PlayerState.CharacterName;
@@ -952,7 +956,7 @@ internal sealed class DataManagement : ISettingsTab
var elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop();
- Plugin.Log.Info(
+ Plugin.LogProxy.Info(
$"Crafted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)"
);
@@ -962,7 +966,7 @@ internal sealed class DataManagement : ISettingsTab
elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop();
- Plugin.Log.Info(
+ Plugin.LogProxy.Info(
$"Upserted {count} messages in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)"
);
@@ -973,7 +977,7 @@ internal sealed class DataManagement : ISettingsTab
Plugin.MessageManager.ClearAllTabs();
elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop();
- Plugin.Log.Info(
+ Plugin.LogProxy.Info(
$"Cleared {Plugin.Config.Tabs.Count} tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)"
);
})
@@ -986,7 +990,7 @@ internal sealed class DataManagement : ISettingsTab
Plugin.MessageManager.FilterAllTabs();
elapsedTicks = stopwatch.ElapsedTicks;
stopwatch.Stop();
- Plugin.Log.Info(
+ Plugin.LogProxy.Info(
$"Fetched and filtered all tabs in {elapsedTicks} ticks ({elapsedTicks / TimeSpan.TicksPerMillisecond}ms)"
);
})
diff --git a/HellionChat/Ui/SettingsTabs/FontsAndColours.cs b/HellionChat/Ui/SettingsTabs/FontsAndColours.cs
index 759f1f7..7276549 100644
--- a/HellionChat/Ui/SettingsTabs/FontsAndColours.cs
+++ b/HellionChat/Ui/SettingsTabs/FontsAndColours.cs
@@ -312,6 +312,6 @@ internal sealed class FontsAndColours : ISettingsTab
}
Plugin.SaveConfig();
GlobalParametersCache.Refresh();
- Plugin.Log.Debug($"Applied chat colour preset: {preset.DisplayName}");
+ Plugin.LogProxy.Debug($"Applied chat colour preset: {preset.DisplayName}");
}
}
diff --git a/HellionChat/Ui/SettingsTabs/Integrations.cs b/HellionChat/Ui/SettingsTabs/Integrations.cs
index b422764..4f7792d 100644
--- a/HellionChat/Ui/SettingsTabs/Integrations.cs
+++ b/HellionChat/Ui/SettingsTabs/Integrations.cs
@@ -71,6 +71,17 @@ internal sealed class Integrations : ISettingsTab
{
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
diff --git a/HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs b/HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs
index d87275d..34baf54 100644
--- a/HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs
+++ b/HellionChat/Ui/SettingsTabs/ThemeAndLayout.cs
@@ -90,7 +90,7 @@ internal sealed class ThemeAndLayout : ISettingsTab
var path = Path.Combine(dir, fileName);
var json = ThemeJsonWriter.Serialize(active);
File.WriteAllText(path, json);
- Plugin.Log.Information($"Exported active theme '{active.Slug}' to {path}");
+ Plugin.LogProxy.Information($"Exported active theme '{active.Slug}' to {path}");
}
}
}
@@ -250,6 +250,26 @@ internal sealed class ThemeAndLayout : ISettingsTab
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();
diff --git a/HellionChat/Util/AutoTranslate.cs b/HellionChat/Util/AutoTranslate.cs
index fa4e0b5..80b4c01 100644
--- a/HellionChat/Util/AutoTranslate.cs
+++ b/HellionChat/Util/AutoTranslate.cs
@@ -62,7 +62,7 @@ internal static class AutoTranslate
{
var sw = Stopwatch.StartNew();
AllEntries();
- Plugin.Log.Debug($"Warming up auto-translate took {sw.ElapsedMilliseconds}ms");
+ Plugin.LogProxy.Debug($"Warming up auto-translate took {sw.ElapsedMilliseconds}ms");
})
{
IsBackground = true,
@@ -197,7 +197,7 @@ internal static class AutoTranslate
}
catch (Exception ex)
{
- Plugin.Log.Error(ex, $"failed to translate: {lookup}");
+ Plugin.LogProxy.Error(ex, $"failed to translate: {lookup}");
}
}
diff --git a/HellionChat/Util/DalamudPluginLogProxy.cs b/HellionChat/Util/DalamudPluginLogProxy.cs
new file mode 100644
index 0000000..a6e9ad6
--- /dev/null
+++ b/HellionChat/Util/DalamudPluginLogProxy.cs
@@ -0,0 +1,61 @@
+using System;
+using Dalamud.Plugin.Services;
+
+namespace HellionChat.Util;
+
+internal sealed class DalamudPluginLogProxy : IPluginLogProxy
+{
+ private readonly IPluginLog _log;
+
+ public DalamudPluginLogProxy(IPluginLog log) => _log = log;
+
+ public void Verbose(string message) => _log.Verbose(message);
+
+ public void Verbose(Exception exception, string message) => _log.Verbose(exception, message);
+
+ public void Verbose(string messageTemplate, params object[] values) =>
+ _log.Verbose(messageTemplate, values);
+
+ public void Debug(string message) => _log.Debug(message);
+
+ public void Debug(Exception exception, string message) => _log.Debug(exception, message);
+
+ public void Debug(string messageTemplate, params object[] values) =>
+ _log.Debug(messageTemplate, values);
+
+ public void Information(string message) => _log.Information(message);
+
+ public void Information(Exception exception, string message) =>
+ _log.Information(exception, message);
+
+ public void Information(string messageTemplate, params object[] values) =>
+ _log.Information(messageTemplate, values);
+
+ public void Info(string message) => _log.Info(message);
+
+ public void Info(Exception exception, string message) => _log.Info(exception, message);
+
+ public void Info(string messageTemplate, params object[] values) =>
+ _log.Info(messageTemplate, values);
+
+ public void Warning(string message) => _log.Warning(message);
+
+ public void Warning(Exception exception, string message) => _log.Warning(exception, message);
+
+ public void Warning(string messageTemplate, params object[] values) =>
+ _log.Warning(messageTemplate, values);
+
+ public void Error(string message) => _log.Error(message);
+
+ public void Error(Exception exception, string message) => _log.Error(exception, message);
+
+ public void Error(string messageTemplate, params object[] values) =>
+ _log.Error(messageTemplate, values);
+
+ public void Fatal(string message) => _log.Fatal(message);
+
+ public void Fatal(Exception exception, string message) => _log.Fatal(exception, message);
+
+ public void Fatal(string messageTemplate, params object[] values) =>
+ _log.Fatal(messageTemplate, values);
+}
diff --git a/HellionChat/Util/IPluginLogProxy.cs b/HellionChat/Util/IPluginLogProxy.cs
new file mode 100644
index 0000000..40fd4bc
--- /dev/null
+++ b/HellionChat/Util/IPluginLogProxy.cs
@@ -0,0 +1,40 @@
+using System;
+
+namespace HellionChat.Util;
+
+// Indirection over Dalamud's IPluginLog so MessageStore can be constructed
+// in an isolated xUnit AppDomain without loading Dalamud.dll — same pattern
+// as IPlatformUtil from F12.1. A later DI-container cycle (v1.5.x) may
+// replace this with Microsoft.Extensions.Logging's ILogger.
+internal interface IPluginLogProxy
+{
+ void Verbose(string message);
+ void Verbose(Exception exception, string message);
+ void Verbose(string messageTemplate, params object[] values);
+
+ void Debug(string message);
+ void Debug(Exception exception, string message);
+ void Debug(string messageTemplate, params object[] values);
+
+ void Information(string message);
+ void Information(Exception exception, string message);
+ void Information(string messageTemplate, params object[] values);
+
+ // IPluginLog exposes Info as a distinct method (short alias of
+ // Information) — both are present so call-sites stay drop-in.
+ void Info(string message);
+ void Info(Exception exception, string message);
+ void Info(string messageTemplate, params object[] values);
+
+ void Warning(string message);
+ void Warning(Exception exception, string message);
+ void Warning(string messageTemplate, params object[] values);
+
+ void Error(string message);
+ void Error(Exception exception, string message);
+ void Error(string messageTemplate, params object[] values);
+
+ void Fatal(string message);
+ void Fatal(Exception exception, string message);
+ void Fatal(string messageTemplate, params object[] values);
+}
diff --git a/HellionChat/Util/ImGuiUtil.cs b/HellionChat/Util/ImGuiUtil.cs
index 4db4141..cc5187a 100755
--- a/HellionChat/Util/ImGuiUtil.cs
+++ b/HellionChat/Util/ImGuiUtil.cs
@@ -583,7 +583,9 @@ internal static class ImGuiUtil
using (ImRaii.Disabled(isMax))
{
- if (IconButton(FontAwesomeIcon.ArrowRight, id + 1.ToString()))
+ // Parentheses pin the operator precedence: without them this resolves as
+ // id.ToString() + "1" (e.g. "01" instead of "1").
+ if (IconButton(FontAwesomeIcon.ArrowRight, (id + 1).ToString()))
selected++;
}
diff --git a/HellionChat/Util/MemoryUtil.cs b/HellionChat/Util/MemoryUtil.cs
index cfd9885..cba3f45 100644
--- a/HellionChat/Util/MemoryUtil.cs
+++ b/HellionChat/Util/MemoryUtil.cs
@@ -42,6 +42,6 @@ public static class MemoryUtil
str.Append(' ');
}
- Plugin.Log.Information(str.ToString());
+ Plugin.LogProxy.Information(str.ToString());
}
}
diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs
new file mode 100644
index 0000000..058bdfb
--- /dev/null
+++ b/HellionChat/Util/TabLifecycleHelpers.cs
@@ -0,0 +1,16 @@
+namespace HellionChat.Util;
+
+// Pure predicates for the TempTab pin lifecycle. Extracted from the strip
+// sites in Plugin.cs and Configuration.cs so they stay in lockstep — a
+// load-time strip that disagrees with the save-time strip is exactly how
+// pinned tabs would silently fall out of the JSON.
+internal static class TabLifecycleHelpers
+{
+ public static bool IsInUnpinnedPool(Tab t) => t.IsTempTab && !t.IsPinned;
+
+ public static bool IsInPinnedPool(Tab t) => t.IsTempTab && t.IsPinned;
+
+ public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t);
+
+ public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t);
+}
diff --git a/HellionChat/Util/WrapperUtil.cs b/HellionChat/Util/WrapperUtil.cs
index 4a4270c..4c44d89 100644
--- a/HellionChat/Util/WrapperUtil.cs
+++ b/HellionChat/Util/WrapperUtil.cs
@@ -21,12 +21,12 @@ public static class WrapperUtil
{
try
{
- Plugin.Log.Debug($"Opening URI {uri} in default browser");
+ Plugin.LogProxy.Debug($"Opening URI {uri} in default browser");
Plugin.PlatformUtil.OpenLink(uri.ToString());
}
catch (Exception ex)
{
- Plugin.Log.Error($"Error opening URI: {ex}");
+ Plugin.LogProxy.Error($"Error opening URI: {ex}");
AddNotification(Language.Context_OpenInBrowserError, NotificationType.Error);
}
}
diff --git a/README.md b/README.md
index 46ddd17..ebec976 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
[](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/actions/workflows/build.yml)
[](LICENSE)
-[](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest)
+[](https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/latest)
[](https://github.com/goatcorp/Dalamud)
[](https://dotnet.microsoft.com/)
[](https://www.finalfantasyxiv.com/)
@@ -11,7 +11,7 @@
-**Version 1.4.6** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on
+**Version 1.4.7** — Privacy-first chat plugin for FINAL FANTASY XIV / Dalamud, built on
[Chat 2](https://github.com/Infiziert90/ChatTwo) (EUPL-1.2).
Hellion Chat is a privacy-first plugin built on the Chat 2 foundation. The majority of the engine comes from Chat 2
@@ -286,20 +286,23 @@ An optional submission to the Dalamud main plugin repo (in addition to the custo
## Project Status
-**Version 1.4.6** — Maintenance patch. No user-visible behaviour changes; tightens the development feedback loop and
-pulls in two ChatTwo upstream bugfixes. `scripts/preflight.sh` gains a csharpier reflow check and a markdownlint pass at
-the pre-push gate. `FontManager`'s font-fallback catch-filter now covers `InvalidOperationException` and
-`ArgumentException` on top of the IO triad, so a corrupted font config no longer takes down the atlas build.
-`BrandingLinks` and `IntegrationLinks` URLs validate themselves on plugin load — a typo in a future URL rotation throws
-at startup instead of failing silently when a user clicks the broken button. Cherry-picked from ChatTwo upstream
-`f35b7d3`: `Chat.SetChannel` no longer leaks the native `Utf8String` when the linkshell check rejects the channel, and
-`Tab.Clone` now deep-clones `UsedChannel` and `TellTarget` (the previous reference copy let PopOut and Temp tabs mutate
-each other's channel state). The active-tab underline pill scales with DPI and rounds to physical pixels for crisp
-rendering above 100 % DPI. Internal items: `HellionStyle` ChildBgAlpha extracted to a testable helper,
-`Plugin.SaveConfig` clones only the temp-tab subset, `SettingsOverview` caches the draw-list per frame,
-`Dalamud.Utility.Util` static surface routed through an `IPlatformUtil` indirection (`MessageStore`'s `IsWine` probe is
-now testable in isolation). No schema bump, no migration. Seventh sub-patch of the v1.4.x polish sweep series (as of
-2026-05-12).
+**Version 1.4.7** — Backlog cleanup and the first user-visible feature bundle since v1.4.5. TempTell tabs can now be
+pinned via right-click; pinned tabs survive relog, keep their conversation history (loaded on demand from the message
+store), and stay bound to the same `/tell` partner. A hard cap of 5 pinned tabs lives in a pool separate from the 15-tab
+auto-tell pool, so the total ceiling is 20 tabs. The sidebar groups pinned tabs into their own section with its own
+divider header. Honorific glow outlines now render when the title carries a Glow colour — opt-in via **Settings →
+Integrations → Render glow outlines (Honorific)**, default off, so v1.4.6 visuals stay untouched for users who don't
+care and the per-frame DrawList overhead is skipped on low-end hardware. Honorific gradient (Color3 / GradientColourSet
+/ Wave / Pulse) is parsed and stashed for a later cycle, but currently renders as the primary colour. Sidebar width is
+configurable in **Theme & Layout** between 44 and 160 px; default stays icon-only so existing users see no layout
+change. `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the persistent-tab merge, and
+`TabSwitched` deep-clones the seeded channel — together they fix a Settings-Save regression where the chat input could
+pop back to `/tell ` after touching settings while on a Party or Linkshell tab. Internal items:
+`IPluginLogProxy` indirection over Dalamud's `IPluginLog` routes all ~91 `Plugin.Log` call sites through a testable
+proxy, closing the test-isolation gap F12.1 left in v1.4.6 (`MessageStore.Migrate0` now runs in xUnit without loading
+`Dalamud.dll`). `Util/ImGuiUtil.cs`'s `DrawArrows` IconButton id gets explicit parentheses on the increment. Migration
+v16 → v17 is additive (new `Tab.IsPinned` flag, default false). Eighth sub-patch of the v1.4.x polish sweep series (as
+of 2026-05-13).
Hellion Chat is a standalone plugin, no longer a fork in the repository sense. Fully completed:
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 8f14bb0..7f440a3 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -10,6 +10,45 @@ to the release pages for details.
---
+## Hellion Chat 1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)
+
+Eighth sub-patch of the v1.4.x polish-sweep series. First user-visible feature bundle since v1.4.5 — pinned tell tabs
+that survive relog, opt-in Honorific glow rendering, a configurable sidebar, plus a Settings-Save channel-preservation
+fix surfaced during smoke testing.
+
+- TempTell Pin: right-click a TempTell tab in the sidebar and choose "Pin Tab" / "Tab anpinnen". Pinned tabs survive
+ plugin reload and character logout, keep their conversation history (loaded on demand from the message store on
+ rehydrate), and stay bound to the same `/tell` partner. Hard cap of 5 pinned tabs in a pool separate from the 15-tab
+ auto-tell pool — total ceiling is 20 tabs. The sidebar groups pinned tabs into their own section with a divider header
+- Honorific glow outlines now render via an 8-direction DrawList pre-pass when the title carries a Glow colour. Opt-in
+ via **Settings → Integrations → Render glow outlines (Honorific)** (default off). Honorific's gradient surface
+ (`Color3`, `GradientColourSet`, `GradientAnimationStyle`) is parsed and stashed for a later cycle but renders as the
+ primary colour until then — the v1.4.7 DTO already mirrors all four extra fields so the JSON roundtrip doesn't
+ silent-drop them
+- Sidebar width configurable in **Theme & Layout** (44–160 px, default 44 stays icon-only). The icon button stretches
+ with the configured width so a widened sidebar looks intentional, not a 36 px icon floating in empty space
+- `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the persistent-tab merge alongside
+ `Messages` and `LastSendUnread`. `TabSwitched` deep-clones the seeded channel from the previous tab instead of sharing
+ the same `UsedChannel` instance. Together these fix a regression where Settings-Save on a Party or Linkshell tab
+ popped the chat input back to `/tell ` on the next interaction
+- `Util/ImGuiUtil.cs` `DrawArrows` IconButton id uses `(id + 1).ToString()` with explicit parentheses instead of the
+ operator-precedence quirk `id + 1.ToString()` (which resolved to `id.ToString() + "1"`). Single live caller is
+ `Ui/DbViewer.cs:227` page-navigation
+- Internal: `IPluginLogProxy` indirection over Dalamud's `IPluginLog` routes all ~91 `Plugin.Log` call sites through a
+ testable proxy. `MessageStore.Migrate0` can now run in xUnit without loading `Dalamud.dll`, closing the gap F12.1 left
+ in v1.4.6. Production wrapper `DalamudPluginLogProxy` and Build-Suite `FakePluginLogProxy` mirror the full
+ `IPluginLog` surface (`Verbose`/`Debug`/`Information`/`Info`/`Warning`/`Error`/`Fatal`) with single-string,
+ `Exception+string`, and `params object[]` overloads
+- Internal: TempTab counter switched from an `Interlocked` cached field to a derived `Tabs.Count(predicate)`. Pin-state
+ transitions (TryPin / Unpin / Promote) are cold-path and don't need lock-free reads; counter mutation surface dropped
+ from 5 to 0 sites. Build-Suite floor 688 → 710 (+22)
+- Schema bump v16 → v17 is additive: new `Tab.IsPinned` bool, default false. Existing v16 configs load cleanly and get
+ their `Version` stamp bumped after the gate check
+
+Based on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).
+
+---
+
## Hellion Chat 1.4.6 — Code Hygiene and Refactor (2026-05-12)
Maintenance patch. No user-visible behaviour changes; tightens the development feedback loop, fixes two
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index ebece1e..8af91c5 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -10,14 +10,34 @@ the plugin's privacy-first scope during brainstorming.
---
-## Next Cycle (v1.4.7)
+## Next Cycle (v1.4.8)
-**Backlog Cleanup.** Roll up the remaining audit items deferred from v1.4.0–v1.4.6 and the new entries surfaced during
-v1.4.6 (notably the `Plugin.Log` indirection that would unlock fully isolated `MessageStore` construction tests, plus
-follow-up scope hinted at in the ChatTwo upstream f35b7d3 cherry-picks). Scope is consolidated during brainstorm.
+**Hook-Layer Cycle.** Receive-suppressed-tells toggle (cross-reference XIVIM #73 bubble-layer sub-task), Database Viewer
+full-text search via SQLite FTS5, plus preparation for the later Ad-Block cycle. Hook-layer investigation is shared
+across these items so they cluster naturally in one sub-patch.
---
+## v1.4.7 — Backlog Cleanup and Mid-Features (released 2026-05-13)
+
+Eighth sub-patch of the v1.4.x Polish Sweep series. First user-visible feature bundle since v1.4.5. TempTell tabs can
+now be pinned via right-click; pinned tabs survive plugin reload and character logout, keep their conversation history
+(loaded on demand from the message store on rehydrate), and stay bound to the same `/tell` partner. A hard cap of 5
+pinned tabs lives in a pool separate from the 15-tab auto-tell pool, total ceiling 20. The sidebar groups pinned tabs
+into their own section with a divider header, and the sidebar width itself is now configurable in **Theme & Layout**
+between 44 and 160 px. Honorific glow outlines render when the title carries a Glow colour, opt-in via **Settings →
+Integrations → Render glow outlines (Honorific)** (default off). Honorific's gradient (Color3 / GradientColourSet / Wave
+/ Pulse) is parsed but rendered statically — a later cycle will port the full animation algorithm or land an upstream
+IPC PR for the resolved frame colour. `Configuration.UpdateFrom` now preserves the runtime `CurrentChannel` across the
+persistent-tab merge, and `TabSwitched` deep-clones the seeded channel instead of sharing the previous tab's
+`UsedChannel` — together they fix a Settings-Save regression where the chat input could pop back to
+`/tell ` after touching settings on a Party or Linkshell tab. Internal items: `IPluginLogProxy`
+indirection over Dalamud's `IPluginLog` routes all ~91 `Plugin.Log` call sites through a testable proxy, closing the
+F12.1 test-isolation gap (`MessageStore.Migrate0` runs in xUnit now). TempTab counter switched from `Interlocked` cached
+field to derived `Tabs.Count(predicate)`. Migration v16 → v17 is additive (new `Tab.IsPinned` flag). Build-Suite floor
+688 → 710 (+22 tests across Pin-lifecycle predicates, pool limits, Tab.Clone roundtrip, MessageStore Migrate0
+construction, and Honorific TitleData JSON roundtrip).
+
## v1.4.6 — Code Hygiene and Refactor (released 2026-05-12)
Seventh sub-patch of the v1.4.x Polish Sweep series. Maintenance patch — no user-visible behaviour changes; tightens the
diff --git a/repo.json b/repo.json
index 81b8df0..526d5ba 100644
--- a/repo.json
+++ b/repo.json
@@ -3,7 +3,7 @@
"Author": "Jon Kazama (Hellion Forge)",
"Name": "Hellion Chat",
"InternalName": "HellionChat",
- "AssemblyVersion": "1.4.6.0",
+ "AssemblyVersion": "1.4.7.0",
"Description": "A Hellion Forge plugin — privacy-focused chat replacement for FINAL FANTASY XIV, built for EU, US and JP data rules.\n\nBy default only your own conversations are stored. Public chat, NPC dialogue, system messages and battle logs are discarded at the storage layer unless you opt in. Retention windows are configurable per channel, history can be wiped retroactively, and everything can be exported on demand.\n\nFeatures:\n- Channel whitelist with a Privacy-First default\n- Per-channel retention with a daily background sweep\n- Retroactive cleanup with preview and Ctrl+Shift confirm\n- Export to Markdown, JSON or CSV\n- First-run wizard with three profiles: Privacy-First, Casual, Full History\n- Bilingual UI (EN/DE) with live language switching\n- Own config and database — no shared state with other plugins\n\nBased on Chat 2 by Infi and Anna (EUPL-1.2).\nSupport: https://discord.gg/X9V7Kcv5gR",
"ApplicableVersion": "any",
"RepoUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat",
@@ -14,12 +14,12 @@
"CanUnloadAsync": false,
"LoadPriority": 0,
"Punchline": "A Hellion Forge plugin. Privacy-first chat for FFXIV, built to stay out of your way.",
- "Changelog": "**v1.4.6 — Code Hygiene and Refactor (2026-05-12)**\n\nMaintenance patch. No user-visible behaviour changes; tightens the development feedback loop, fixes two upstream-inherited bugs, and prepares the code for the v1.4.7 backlog cleanup.\n\n- preflight.sh gains a csharpier reflow check and a markdownlint pass so style drift and markdown violations are caught at the pre-push gate\n- FontManager fallback catches the full set of atlas-toolkit throws (IO, InvalidOperation, ArgumentException) — a corrupt font config no longer takes down the whole atlas build\n- BrandingLinks and IntegrationLinks URLs validated on plugin load — a typo in a future URL rotation now throws at startup\n- Cherry-picked from ChatTwo upstream f35b7d3: Chat.SetChannel no longer leaks the native Utf8String when the linkshell check rejects the channel\n- Cherry-picked from ChatTwo upstream f35b7d3: Tab.Clone now deep-clones UsedChannel and TellTarget — PopOut and Temp tabs no longer mutate each other's channel state\n- Active-tab underline scales with DPI and rounds to physical pixels for crisp rendering above 100% scaling\n- IconButton width parameter no longer subtracts HUD-scaled padding from a raw int (measured width passes through verbatim)\n- Internal: HellionStyle ChildBgAlpha extracted to a testable helper; Plugin.SaveConfig clones only the temp tabs; SettingsOverview caches the draw-list per frame; Dalamud.Utility.Util surface routed through an IPlatformUtil indirection (MessageStore IsWine probe is now testable in isolation)\n- Built-in themes: Crystal Nocturne (sapphire and electric magenta over obsidian, by CRYSTALLITE) replaces Moonlit Bloom. Users with Moonlit Bloom selected fall back to Hellion Arctic on first load\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.5 — UX and Robustness (2026-05-12)**\n\nSixth sub-patch of the v1.4.x polish-sweep series. Chat-log draw failures surface as a notification, the first-run wizard has an explicit Later option, the input history clears on plugin reload, and the status bar version slot stops clipping in narrow windows.\n\n- Chat window draw errors now show a one-shot notification instead of failing silently — stack trace stays in /xllog\n- First-run wizard: explicit \"Later — keep defaults\" button. Closing the X no longer silently accepts the defaults; the wizard reopens on the next plugin load if nothing was picked\n- InputHistoryService clears on plugin dispose so the previous session's typed commands don't bleed into the next load\n- Status bar hides the version slot when the chat window is too narrow to fit all five slots without overlap\n- Internal: explicit session-only Auto-Tell-Tab invariant in Plugin.cs plus a pinning test in the Build-Suite\n- Internal: FontManager falls back to the system font if the embedded Hellion font resource is missing — logs a Warning\n\n---\n\n**v1.4.4 — Threading and IPC safety polish (2026-05-12)**\n\nFifth sub-patch of the v1.4.x polish-sweep series. Threading assumptions are documented per-method, a hot-path lock falls away, and the privacy filter speaks up when an unknown ChatType shows up.\n\n- AutoTellTabs hot-path getter uses an Interlocked counter instead of taking the lock on every read\n- Honorific integration: per-method threading banners, plus Warning-level log on unsubscribe failure\n- AutoTranslate warmup thread marked IsBackground so plugin unload doesn't wait for it\n- PrivacyFilter logs once per unknown ChatType so a future patch's added channel doesn't drop off the radar\n- New installs persist unknown channels by default; existing configs keep their explicit choice\n\n---\n\n**v1.4.3 — Faster plugin load + new repo (2026-05-08)**\n\nHeavy startup work (migrations, hooks, windows) now runs async so Dalamud's UI stays responsive during load. Load time is comparable to v1.4.2 — this is the foundation for v1.4.4 optimisations.\n\n- Two-phase async load via IAsyncDalamudPlugin\n- Schema-gate replaces the v9→v16 migration chain; old configs require a v1.4.2 install first\n- AutoTranslate cache loads on first use instead of every startup\n- Custom font (Hellion-Exo2) appears with a brief pop after load\n- Repo moved to gitea.hellion-forge.cloud — update your custom-repo URL\n\n---\n\nFull history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases",
+ "Changelog": "**v1.4.7 — Backlog Cleanup and Mid-Features (2026-05-13)**\n\nEighth sub-patch of the v1.4.x polish-sweep series. First user-visible feature bundle since v1.4.5 — pinned tell tabs that survive relog, opt-in Honorific glow rendering, and a configurable sidebar.\n\n- TempTell Pin: right-click a TempTell tab in the sidebar to pin it. Pinned tabs survive relog, keep their conversation history (loaded on demand from the message store), and stay bound to the same /tell partner. Hard cap of 5 pinned tabs in a pool separate from the 15-tab auto-tell pool — total ceiling is 20 tabs. New 'Angepinnt' / 'Pinned' section in the sidebar with its own divider header\n- Honorific Glow outline now renders when the title carries a Glow colour. Opt-in via Settings → Integrations → 'Render glow outlines (Honorific)' (default off, dodges the per-frame DrawList overhead on low-end hardware). Gradient (Color3 / GradientColourSet / Wave / Pulse) is parsed but rendered statically — a later cycle will port the full animation\n- Sidebar width is now configurable in Theme & Layout (range 44–160 px). Default stays icon-only; widen to fit section headers like 'Aktive Tells (3)' without truncation\n- Settings Save no longer pops the chat input back to /tell with a pinned partner — Configuration.UpdateFrom now preserves the runtime CurrentChannel across the persistent-tab merge, and TabSwitched deep-clones the seeded channel instead of sharing the previous tab's UsedChannel\n- Util/ImGuiUtil.cs DrawArrows IconButton id now uses (id + 1).ToString() instead of the operator-precedence quirk id + 1.ToString() — generated IDs stay numerically stable\n- Internal: IPluginLogProxy indirection over Dalamud's IPluginLog routes all ~91 Plugin.Log call sites through a testable proxy. MessageStore.Migrate0 can now run in xUnit without loading Dalamud.dll, closing the gap F12.1 left in v1.4.6\n- Internal: TempTab counter switched from an Interlocked cached field to a derived Tabs.Count(predicate) — pin-state transitions are cold-path and don't need lock-free reads\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.6 — Code Hygiene and Refactor (2026-05-12)**\n\nMaintenance patch. No user-visible behaviour changes; tightens the development feedback loop, fixes two upstream-inherited bugs, and prepares the code for the v1.4.7 backlog cleanup.\n\n- preflight.sh gains a csharpier reflow check and a markdownlint pass so style drift and markdown violations are caught at the pre-push gate\n- FontManager fallback catches the full set of atlas-toolkit throws (IO, InvalidOperation, ArgumentException) — a corrupt font config no longer takes down the whole atlas build\n- BrandingLinks and IntegrationLinks URLs validated on plugin load — a typo in a future URL rotation now throws at startup\n- Cherry-picked from ChatTwo upstream f35b7d3: Chat.SetChannel no longer leaks the native Utf8String when the linkshell check rejects the channel\n- Cherry-picked from ChatTwo upstream f35b7d3: Tab.Clone now deep-clones UsedChannel and TellTarget — PopOut and Temp tabs no longer mutate each other's channel state\n- Active-tab underline scales with DPI and rounds to physical pixels for crisp rendering above 100% scaling\n- IconButton width parameter no longer subtracts HUD-scaled padding from a raw int (measured width passes through verbatim)\n- Internal: HellionStyle ChildBgAlpha extracted to a testable helper; Plugin.SaveConfig clones only the temp tabs; SettingsOverview caches the draw-list per frame; Dalamud.Utility.Util surface routed through an IPlatformUtil indirection (MessageStore IsWine probe is now testable in isolation)\n- Built-in themes: Crystal Nocturne (sapphire and electric magenta over obsidian, by CRYSTALLITE) replaces Moonlit Bloom. Users with Moonlit Bloom selected fall back to Hellion Arctic on first load\n\nBased on Chat 2 1.35.3 (upstream Infiziert90/ChatTwo, EUPL-1.2).\n\n---\n\n**v1.4.5 — UX and Robustness (2026-05-12)**\n\nSixth sub-patch of the v1.4.x polish-sweep series. Chat-log draw failures surface as a notification, the first-run wizard has an explicit Later option, the input history clears on plugin reload, and the status bar version slot stops clipping in narrow windows.\n\n- Chat window draw errors now show a one-shot notification instead of failing silently — stack trace stays in /xllog\n- First-run wizard: explicit \"Later — keep defaults\" button. Closing the X no longer silently accepts the defaults; the wizard reopens on the next plugin load if nothing was picked\n- InputHistoryService clears on plugin dispose so the previous session's typed commands don't bleed into the next load\n- Status bar hides the version slot when the chat window is too narrow to fit all five slots without overlap\n- Internal: explicit session-only Auto-Tell-Tab invariant in Plugin.cs plus a pinning test in the Build-Suite\n- Internal: FontManager falls back to the system font if the embedded Hellion font resource is missing — logs a Warning\n\n---\n\n**v1.4.4 — Threading and IPC safety polish (2026-05-12)**\n\nFifth sub-patch of the v1.4.x polish-sweep series. Threading assumptions are documented per-method, a hot-path lock falls away, and the privacy filter speaks up when an unknown ChatType shows up.\n\n- AutoTellTabs hot-path getter uses an Interlocked counter instead of taking the lock on every read\n- Honorific integration: per-method threading banners, plus Warning-level log on unsubscribe failure\n- AutoTranslate warmup thread marked IsBackground so plugin unload doesn't wait for it\n- PrivacyFilter logs once per unknown ChatType so a future patch's added channel doesn't drop off the radar\n- New installs persist unknown channels by default; existing configs keep their explicit choice\n\n---\n\nFull history: https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases",
"AcceptsFeedback": true,
- "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.6/latest.zip",
- "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.6/latest.zip",
- "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.6/latest.zip",
- "TestingAssemblyVersion": "1.4.6.0",
+ "DownloadLinkInstall": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.7/latest.zip",
+ "DownloadLinkUpdate": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.7/latest.zip",
+ "DownloadLinkTesting": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/releases/download/v1.4.7/latest.zip",
+ "TestingAssemblyVersion": "1.4.7.0",
"IconUrl": "https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/icon.png",
"ImageUrls": [
"https://gitea.hellion-forge.cloud/JonKazama-Hellion/HellionChat/raw/branch/main/HellionChat/images/chatWindow.png",