Files
HellionChat/HellionChat/Plugin.cs
T
JonKazama-Hellion 74bcb91b65 feat(themes): auto-reload active custom theme on disk change
When the user edits their active custom theme JSON in an external editor
and saves, the change now propagates to HellionChat within ~1 second
without re-selecting the theme in the picker.

RefreshActiveIfStale runs from Plugin.Draw on every frame but the actual
File.GetLastWriteTimeUtc stat is 1Hz-throttled -- 60fps would otherwise
mean 3600 stats/min, more on Wine. Built-in themes short-circuit on the
IsBuiltIn check; custom themes without a captured source path (Switch
fell to default) short-circuit on the null check.

Switch() now captures the source path of custom themes via an out-param
on LoadCustomBySlug, which now reverse-looks-up against the existing
_customCache (no re-parse, no extra disk IO). Plugin.LoadAsync warms the
cache via AllCustom() once before the first Switch so a Config.Theme
pointing at a custom slug does not fall through to the built-in default
on a cold registry.

Switch's lookup order is now built-in-first to match Get(slug), so a
user-authored JSON that declares a built-in slug is consistently
ignored in both code paths.

Pure-helper ThemeStampDiff isolates the stamp-diff rules for the
Build-Suite (covers DateTime.MinValue hold-the-line semantics).

v1.4.8 B2.
2026-05-13 23:22:14 +02:00

884 lines
34 KiB
C#
Executable File

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Runtime.ExceptionServices;
using Dalamud.Bindings.ImGui;
using Dalamud.Game.ClientState.Conditions;
using Dalamud.Interface.ImGuiFileDialog;
using Dalamud.Interface.Windowing;
using Dalamud.IoC;
using Dalamud.Plugin;
using Dalamud.Plugin.Services;
using HellionChat.Ipc;
using HellionChat.Resources;
using HellionChat.Ui;
using HellionChat.Util;
using Microsoft.Data.Sqlite;
namespace HellionChat;
// ReSharper disable once ClassNeverInstantiated.Global
public sealed class Plugin : IAsyncDalamudPlugin
{
public const string PluginName = "Hellion Chat";
[PluginService]
public static IPluginLog Log { get; private set; } = null!;
[PluginService]
public static IDalamudPluginInterface Interface { get; private set; } = null!;
[PluginService]
public static IChatGui ChatGui { get; private set; } = null!;
[PluginService]
public static IClientState ClientState { get; private set; } = null!;
[PluginService]
public static ICommandManager CommandManager { get; private set; } = null!;
[PluginService]
public static ICondition Condition { get; private set; } = null!;
[PluginService]
public static IDataManager DataManager { get; private set; } = null!;
[PluginService]
public static IFramework Framework { get; private set; } = null!;
[PluginService]
public static IGameGui GameGui { get; private set; } = null!;
[PluginService]
public static IKeyState KeyState { get; private set; } = null!;
[PluginService]
public static IObjectTable ObjectTable { get; private set; } = null!;
[PluginService]
public static IPartyList PartyList { get; private set; } = null!;
[PluginService]
public static ITargetManager TargetManager { get; private set; } = null!;
[PluginService]
public static ITextureProvider TextureProvider { get; private set; } = null!;
[PluginService]
public static IGameInteropProvider GameInteropProvider { get; private set; } = null!;
[PluginService]
public static IGameConfig GameConfig { get; private set; } = null!;
[PluginService]
public static INotificationManager Notification { get; private set; } = null!;
[PluginService]
public static IAddonLifecycle AddonLifecycle { get; private set; } = null!;
[PluginService]
public static IPlayerState PlayerState { get; private set; } = null!;
[PluginService]
public static ISeStringEvaluator Evaluator { get; private set; } = null!;
[PluginService]
public static ISelfTestRegistry SelfTestRegistry { get; private set; } = null!;
public static Configuration Config = null!;
public static FileDialogManager FileDialogManager { get; private set; } = null!;
public readonly WindowSystem WindowSystem = new(PluginName);
// Phase-2 services are constructed in LoadAsync; null! shape is kept
// consistent across all properties for clarity.
public SettingsWindow SettingsWindow { get; private set; } = null!;
public ChatLogWindow ChatLogWindow { get; private set; } = null!;
public DbViewer DbViewer { get; private set; } = null!;
public InputPreview InputPreview { get; private set; } = null!;
public CommandHelpWindow CommandHelpWindow { get; private set; } = null!;
public SeStringDebugger SeStringDebugger { get; private set; } = null!;
public FirstRunWizard FirstRunWizard { get; private set; } = null!;
public DebuggerWindow DebuggerWindow { get; private set; } = null!;
internal Commands Commands { get; private set; } = null!;
internal GameFunctions.GameFunctions Functions { get; private set; } = null!;
internal MessageManager MessageManager { get; private set; } = null!;
internal AutoTellTabsService AutoTellTabsService { get; private set; } = null!;
internal IpcManager Ipc { get; private set; } = null!;
internal ExtraChat ExtraChat { get; private set; } = null!;
internal TypingIpc TypingIpc { get; private set; } = null!;
internal FontManager FontManager { get; private set; } = null!;
internal Themes.ThemeRegistry ThemeRegistry { get; private set; } = null!;
internal Ui.StatusBar StatusBar { get; private set; } = null!;
internal Integrations.HonorificService HonorificService { get; private set; } = null!;
// Platform indirection over Dalamud.Utility.Util. Wired in Phase-1 ctor so
// any service allocated in LoadAsync can read Plugin.PlatformUtil.
internal static IPlatformUtil PlatformUtil { get; private set; } = null!;
// 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;
internal int DeferredSaveFrames = -1;
// Cancels the v1.4.8 FTS5 bulk-insert worker on plugin teardown. The
// worker runs off the framework thread on its own SqliteConnection, so a
// Dispose mid-rebuild must signal cancellation before MessageManager
// tears down (the worker logs "rebuild failed" via Log on error paths).
private CancellationTokenSource? _ftsRebuildCts;
// Serialises retention sweeps so a manual trigger and the 24h auto-sweep
// can't run in parallel. Volatile because the ImGui thread reads it outside
// the lock to gate the manual button.
internal readonly object RetentionSweepLock = new();
internal volatile bool RetentionSweepRunning;
internal DateTime GameStarted { get; }
// Tab management lives here rather than in ChatLogWindow for access reasons.
internal int LastTab { get; set; }
internal int? WantedTab { get; set; }
internal Tab CurrentTab
{
get
{
var i = LastTab;
return i > -1 && i < Config.Tabs.Count ? Config.Tabs[i] : new Tab();
}
}
public Plugin()
{
// Phase-1 ctor: bootstrap-essentials only (conflict gate, config load,
// language + ImGui init). All service/window allocation lives in LoadAsync.
// Block load if upstream Chat 2 is active — prevents IPC collisions
// and double-replacement of the in-game chat window.
ChatTwoConflictDetector.ThrowIfChatTwoIsLoaded(Interface);
GameStarted = Process.GetCurrentProcess().StartTime.ToUniversalTime();
// Migrate config + database from upstream ChatTwo on first start.
MigrateFromChatTwoLayout();
Config = Interface.GetPluginConfig() as Configuration ?? new Configuration();
// Wire platform indirection before LoadAsync allocates anything that
// 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. 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.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;
// 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);
DeferredSaveFrames = -1;
}
public async Task LoadAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
// Default tab layout on fresh install. Tells are handled by
// Auto-Tell-Tabs; Novice Network has no preset tab by design.
if (Config.Tabs.Count == 0)
{
Config.Tabs.Add(TabsUtil.VanillaGeneral);
Config.Tabs.Add(TabsUtil.HellionSystem);
Config.Tabs.Add(TabsUtil.HellionFreeCompany);
Config.Tabs.Add(TabsUtil.HellionParty);
Config.Tabs.Add(TabsUtil.HellionLinkshell);
}
cancellationToken.ThrowIfCancellationRequested();
// BuildFonts registers handles with Dalamud's FontAtlas; the atlas
// rebuilds async a few frames later (visible "font-pop" on first load).
FontManager = new FontManager();
FontManager.BuildFonts();
// ThemeRegistry must be wired before the first Draw tick.
var customThemesDir = Path.Combine(Interface.ConfigDirectory.FullName, "themes");
Directory.CreateDirectory(customThemesDir);
SeedExampleThemeIfEmpty(customThemesDir);
ThemeRegistry = new Themes.ThemeRegistry(customThemesDir);
// Warm up the custom-theme cache before the first Switch.
// LoadCustomBySlug is a reverse-lookup over _customCache; on a
// cold cache a Config.Theme that points at a custom slug would
// fall through to the built-in default. AllCustom is a lazy
// enumerable, so iterate it explicitly to materialise the cache.
foreach (var _ in ThemeRegistry.AllCustom()) { }
ThemeRegistry.Switch(Config.Theme);
cancellationToken.ThrowIfCancellationRequested();
// Service allocations — order encodes dependencies.
// HonorificService registers IPC subscribers early to catch
// Ready/Disposing events from the first frame.
FileDialogManager = new FileDialogManager();
Commands = new Commands();
Functions = new GameFunctions.GameFunctions(this);
Ipc = new IpcManager();
TypingIpc = new TypingIpc(this);
ExtraChat = new ExtraChat();
HonorificService = new Integrations.HonorificService(Interface, Log, Framework);
StatusBar = new Ui.StatusBar();
MessageManager = new MessageManager(this);
AutoTellTabsService = new AutoTellTabsService(
this,
MessageManager,
MessageManager.Store
);
AutoTellTabsService.Initialize();
SelfTestRegistry.RegisterTestSteps([new SelfTests.ThemeSwitchSelfTestStep(this)]);
ChatLogWindow = new ChatLogWindow(this);
SettingsWindow = new SettingsWindow(this);
DbViewer = new DbViewer(this);
InputPreview = new InputPreview(ChatLogWindow);
CommandHelpWindow = new CommandHelpWindow(ChatLogWindow);
SeStringDebugger = new SeStringDebugger(this);
DebuggerWindow = new DebuggerWindow(this);
FirstRunWizard = new FirstRunWizard(this);
WindowSystem.AddWindow(ChatLogWindow);
WindowSystem.AddWindow(SettingsWindow);
WindowSystem.AddWindow(DbViewer);
WindowSystem.AddWindow(InputPreview);
WindowSystem.AddWindow(CommandHelpWindow);
WindowSystem.AddWindow(SeStringDebugger);
WindowSystem.AddWindow(DebuggerWindow);
WindowSystem.AddWindow(FirstRunWizard);
if (!Config.FirstRunCompleted)
FirstRunWizard.IsOpen = true;
cancellationToken.ThrowIfCancellationRequested();
Commands.Initialise();
// Daily retention sweep — fire-and-forget, skips when disabled
// or already ran within the past 24 hours.
RunRetentionSweepIfDue();
if (Config.ShowEmotes)
_ = EmoteCache.LoadData();
if (Interface.Reason is not PluginLoadReason.Boot)
MessageManager.FilterAllTabsAsync();
// Kick the FTS5 rebuild worker if Migrate4 just added the schema or
// a previous run was cut short (InitFtsReadyCache leaves _ftsReady
// false in that case). Runs off the framework thread on its own
// SqliteConnection so the live UpsertMessage path keeps flowing
// through the chunked-commit windows.
_ftsRebuildCts = new CancellationTokenSource();
if (!MessageManager.Store.IsFtsIndexBuilt)
{
var token = _ftsRebuildCts.Token;
_ = Task.Run(
async () =>
{
// FQN: Plugin.Notification (Z.74) shadows the type name.
Dalamud.Interface.ImGuiNotification.IActiveNotification? notif = null;
try
{
notif = Notification.AddNotification(
new Dalamud.Interface.ImGuiNotification.Notification
{
Title = "Hellion Chat",
Content = "Indexing chat history for full-text search...",
Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Info,
Minimized = false,
InitialDuration = TimeSpan.FromMinutes(10),
}
);
// Progress<T> raises this callback on the captured
// sync-context (Task.Run worker pool). IActiveNotification
// is ImGui-backed and mutates the UI, so marshal the
// mutation onto the framework thread via RunOnTick.
var progress = new Progress<long>(done =>
{
Framework.RunOnTick(() =>
{
if (notif is { } n)
n.Content = $"Indexing chat history: {done:N0} messages...";
});
});
// Worker-owned connection. Closed+disposed before we
// flip the readiness flag so the DbViewer never sees
// IsFtsIndexBuilt=true while the worker connection
// is still alive.
SqliteConnection? workerConn = null;
try
{
workerConn = MessageManager.Store.OpenSecondaryConnection();
var total = await Task.Run(
() =>
MessageManager.Store.RebuildFtsIndex(
workerConn,
progress,
token
),
token
)
.ConfigureAwait(false);
workerConn.Close();
workerConn.Dispose();
workerConn = null;
MessageManager.Store.MarkFtsIndexBuilt();
if (notif is { } final)
{
final.Content = $"Indexed {total:N0} messages.";
final.Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Success;
final.InitialDuration = TimeSpan.FromSeconds(5);
}
}
finally
{
workerConn?.Dispose();
}
}
catch (OperationCanceledException)
{
notif?.DismissNow();
}
catch (Exception ex)
{
Log.Error(ex, "FTS index rebuild failed");
if (notif is { } err)
{
err.Content =
"Full-text indexing failed -- search will use local filter only.";
err.Type = Dalamud
.Interface
.ImGuiNotification
.NotificationType
.Error;
}
}
},
_ftsRebuildCts.Token
);
}
Interface.UiBuilder.DisableCutsceneUiHide = true;
Interface.UiBuilder.DisableGposeUiHide = true;
#if !DEBUG
// Fire-and-forget — first auto-translate use may have a sub-second
// hitch if the cache hasn't filled yet, but avoids blocking load.
_ = Task.Run(AutoTranslate.PreloadCache, cancellationToken);
#endif
cancellationToken.ThrowIfCancellationRequested();
// Hooks last — all services and windows must be live before
// the first Draw / FrameworkUpdate tick fires.
Framework.Update += FrameworkUpdate;
Interface.UiBuilder.Draw += Draw;
Interface.LanguageChanged += LanguageChanged;
Interface.UiBuilder.OpenMainUi += OpenMainUi;
}
catch
{
try
{
await DisposeAsync().ConfigureAwait(false);
}
catch
{ /* keep original failure */
}
throw;
}
}
[SuppressMessage("ReSharper", "ConditionalAccessQualifierIsNonNullableAccordingToAPIContract")]
public async ValueTask DisposeAsync()
{
// Idempotency guard — second call short-circuits on reload race.
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0)
return;
Exception? failure = null;
// Unsubscribe hooks first — mirrors the hooks-last subscribe order in LoadAsync.
failure = CaptureFailure(failure, () => Interface.UiBuilder.OpenMainUi -= OpenMainUi);
failure = CaptureFailure(failure, () => Interface.LanguageChanged -= LanguageChanged);
failure = CaptureFailure(failure, () => Interface.UiBuilder.Draw -= Draw);
failure = CaptureFailure(failure, () => Framework.Update -= FrameworkUpdate);
// Signal the FTS rebuild worker to bail. Runs before MessageManager
// tears down so the worker's "rebuild failed" log path still finds
// a live Log static. Worker owns its own SqliteConnection and disposes
// it itself; we only flip the cancellation flag here.
failure = CaptureFailure(
failure,
() =>
{
_ftsRebuildCts?.Cancel();
_ftsRebuildCts?.Dispose();
}
);
// Flush a pending DeferredSave — FrameworkUpdate won't fire it anymore.
failure = CaptureFailure(
failure,
() =>
{
if (DeferredSaveFrames >= 0)
{
SaveConfig();
DeferredSaveFrames = -1;
}
}
);
// Unsubscribe AutoTellTabs before MessageManager goes away.
failure = CaptureFailure(failure, () => AutoTellTabsService?.Dispose());
// MessageManager has its own async dispose path (DB flush, thread shutdown).
if (MessageManager is not null)
{
failure = await CaptureFailureAsync(
failure,
() => MessageManager.DisposeAsync().AsTask()
)
.ConfigureAwait(false);
}
// Game-function / IPC / window cleanup must run on the framework thread.
try
{
await Framework
.RunOnFrameworkThread(() =>
{
failure = CaptureFailure(
failure,
() => GameFunctions.GameFunctions.SetChatInteractable(true)
);
// IPC subscribers before windows — prevents a final IPC event
// from reaching a half-torn ChatLogWindow.
failure = CaptureFailure(failure, () => HonorificService?.Dispose());
failure = CaptureFailure(failure, () => TypingIpc?.Dispose());
failure = CaptureFailure(failure, () => ExtraChat?.Dispose());
failure = CaptureFailure(failure, () => Ipc?.Dispose());
failure = CaptureFailure(failure, () => WindowSystem?.RemoveAllWindows());
failure = CaptureFailure(failure, () => ChatLogWindow?.Dispose());
failure = CaptureFailure(failure, () => DbViewer?.Dispose());
failure = CaptureFailure(failure, () => InputPreview?.Dispose());
failure = CaptureFailure(failure, () => SettingsWindow?.Dispose());
failure = CaptureFailure(failure, () => DebuggerWindow?.Dispose());
failure = CaptureFailure(failure, () => SeStringDebugger?.Dispose());
})
.ConfigureAwait(false);
}
catch (Exception ex)
{
failure ??= ex;
}
// Pure-memory cleanups — no Framework / UI / IPC touch.
failure = CaptureFailure(failure, () => Functions?.Dispose());
failure = CaptureFailure(failure, () => Commands?.Dispose());
failure = CaptureFailure(failure, () => EmoteCache.Dispose());
// Static input history would otherwise survive the plugin reload.
failure = CaptureFailure(failure, InputHistoryService.Reset);
if (failure is not null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
// Run cleanup actions individually so a single failure doesn't strand
// the remaining teardown steps.
private static Exception? CaptureFailure(Exception? failure, Action action)
{
try
{
action();
}
catch (Exception ex)
{
failure ??= ex;
}
return failure;
}
private static async ValueTask<Exception?> CaptureFailureAsync(
Exception? failure,
Func<Task> action
)
{
try
{
await action().ConfigureAwait(false);
}
catch (Exception ex)
{
failure ??= ex;
}
return failure;
}
private static void MigrateFromChatTwoLayout()
{
var pluginConfigsDir = Interface.ConfigDirectory.Parent?.FullName;
if (pluginConfigsDir is null)
return;
var legacyConfigFile = Path.Combine(pluginConfigsDir, "ChatTwo.json");
var legacyConfigDir = Path.Combine(pluginConfigsDir, "ChatTwo");
var ourConfigFile = Path.Combine(pluginConfigsDir, "HellionChat.json");
var ourConfigDir = Interface.ConfigDirectory.FullName;
var lockedBlocker = false;
try
{
if (!File.Exists(ourConfigFile) && File.Exists(legacyConfigFile))
{
File.Move(legacyConfigFile, ourConfigFile);
Log.Information(
$"HellionChat: migrated config file {legacyConfigFile} → {ourConfigFile}"
);
}
}
catch (IOException e)
{
Log.Warning(
e,
$"HellionChat: config file move blocked, leaving {legacyConfigFile} in place"
);
lockedBlocker = true;
}
if (!Directory.Exists(legacyConfigDir))
return;
try
{
Directory.CreateDirectory(ourConfigDir);
// Move each file individually so a single locked file (e.g. the
// SQLite db while ChatTwo is still loaded) doesn't abort the rest.
foreach (var file in Directory.EnumerateFiles(legacyConfigDir))
{
var target = Path.Combine(ourConfigDir, Path.GetFileName(file));
if (File.Exists(target))
continue;
try
{
File.Move(file, target);
Log.Information($"HellionChat: migrated file {file} → {target}");
}
catch (IOException e)
{
Log.Warning(
e,
$"HellionChat: file move blocked for {file}, will retry on next load"
);
lockedBlocker = true;
}
}
foreach (var dir in Directory.EnumerateDirectories(legacyConfigDir))
{
var target = Path.Combine(ourConfigDir, Path.GetFileName(dir));
if (Directory.Exists(target))
continue;
try
{
Directory.Move(dir, target);
Log.Information($"HellionChat: migrated subdir {dir} → {target}");
}
catch (IOException e)
{
Log.Warning(
e,
$"HellionChat: subdir move blocked for {dir}, will retry on next load"
);
lockedBlocker = true;
}
}
if (!Directory.EnumerateFileSystemEntries(legacyConfigDir).Any())
{
Directory.Delete(legacyConfigDir);
Log.Information($"HellionChat: removed empty legacy dir {legacyConfigDir}");
}
}
catch (Exception e)
{
Log.Error(e, "HellionChat: layout migration failed, continuing with whatever exists");
}
if (lockedBlocker)
{
Notification.AddNotification(
new Dalamud.Interface.ImGuiNotification.Notification
{
Title = "Hellion Chat",
Content =
"Could not migrate the Chat 2 database — the file appears to be in use. "
+ "Disable Chat 2, fully close the game, then start it again. "
+ "See the README troubleshooting section if the issue persists.",
Type = Dalamud.Interface.ImGuiNotification.NotificationType.Warning,
InitialDuration = TimeSpan.FromSeconds(30),
}
);
}
}
private void OpenMainUi()
{
SettingsWindow.IsOpen = !SettingsWindow.IsOpen;
}
private void RunRetentionSweepIfDue()
{
if (!Config.RetentionEnabled)
return;
if (DateTimeOffset.UtcNow - Config.RetentionLastRunAt < TimeSpan.FromHours(24))
return;
// Snapshot the policy so the user can edit settings while the sweep runs.
var policy = new Dictionary<int, int>();
foreach (var (type, days) in Privacy.PrivacyDefaults.DefaultRetentionDays)
policy[(int)(ushort)type] = days;
foreach (var (type, days) in Config.RetentionPerChannelDays)
policy[(int)(ushort)type] = days;
var defaultDays = Config.RetentionDefaultDays;
// IsBackground = true so a stuck sweep never blocks plugin unload.
new Thread(() =>
{
// Bail early if a manual sweep is already in flight.
lock (RetentionSweepLock)
{
if (RetentionSweepRunning)
return;
RetentionSweepRunning = true;
}
try
{
var deleted = MessageManager.Store.DeleteByRetentionPolicy(policy, defaultDays);
Config.RetentionLastRunAt = DateTimeOffset.UtcNow;
SaveConfig();
if (deleted > 0)
{
Log.Information($"Retention sweep deleted {deleted} expired messages.");
// Run clear+refilter on the framework thread — FilterAllTabsAsync
// is fire-and-forget and would race the next sweep cycle.
Framework
.Run(() =>
{
MessageManager.ClearAllTabs();
MessageManager.FilterAllTabs();
})
.Wait();
}
else
{
Log.Information("Retention sweep ran, nothing expired.");
}
}
catch (Exception e)
{
Log.Error(e, "Retention sweep failed");
}
finally
{
lock (RetentionSweepLock)
RetentionSweepRunning = false;
}
})
{
IsBackground = true,
}.Start();
}
private void Draw()
{
// v1.4.8 B2: pick up external edits of the active custom theme JSON
// without forcing the user to re-click the picker. The disk-stat is
// 1Hz-throttled inside RefreshActiveIfStale, so this is essentially
// free on built-in themes and ~1 stat/second on custom themes.
ThemeRegistry.RefreshActiveIfStale();
// Theme engine is always active; Classic is a theme, not a disabled state.
using IDisposable _style = HellionStyle.PushGlobal(
ThemeRegistry.Active,
Config.WindowOpacity
);
ChatLogWindow.BeginFrame();
if (Config.HideInLoadingScreens && Condition[ConditionFlag.BetweenAreas])
{
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
return;
}
// Hide all plugin windows while the New Game+ menu is open.
if (
Config.HideInNewGamePlusMenu
&& GameFunctions.GameFunctions.IsAddonInteractable(
GameFunctions.GameFunctions.NewGamePlusAddonName
)
)
{
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
return;
}
ChatLogWindow.HideStateCheck();
Interface.UiBuilder.DisableUserUiHide = !Config.HideWhenUiHidden;
ChatLogWindow.DefaultText = ImGui.GetStyle().Colors[(int)ImGuiCol.Text];
using ((Config.FontsEnabled ? FontManager.RegularFont : FontManager.Axis).Push())
WindowSystem.Draw();
ChatLogWindow.FinalizeFrame();
TypingIpc.Update();
FileDialogManager.Draw();
}
internal void SaveConfig()
{
// 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(unpinnedTempTabs);
}
internal void LanguageChanged(string langCode)
{
var info =
Config.LanguageOverride is LanguageOverride.None
? new CultureInfo(langCode)
: new CultureInfo(Config.LanguageOverride.Code());
Language.Culture = info;
HellionStrings.Culture = info;
}
private static readonly string[] ChatAddonNames =
[
"ChatLog",
"ChatLogPanel_0",
"ChatLogPanel_1",
"ChatLogPanel_2",
"ChatLogPanel_3",
];
private void FrameworkUpdate(IFramework framework)
{
if (DeferredSaveFrames >= 0 && DeferredSaveFrames-- == 0)
SaveConfig();
if (!Config.HideChat)
return;
foreach (var name in ChatAddonNames)
if (GameFunctions.GameFunctions.IsAddonInteractable(name))
GameFunctions.GameFunctions.SetAddonInteractable(name, false);
}
public static bool InBattle => Condition[ConditionFlag.InCombat];
public static bool GposeActive => Condition[ConditionFlag.WatchingCutscene];
public static bool CutsceneActive =>
Condition[ConditionFlag.OccupiedInCutSceneEvent]
|| Condition[ConditionFlag.WatchingCutscene78];
// Seeds example-theme.json into the themes dir on first run.
// Skipped if any custom JSON already exists.
private static void SeedExampleThemeIfEmpty(string dir)
{
if (Directory.EnumerateFiles(dir, "*.json").Any())
return;
var examplePath = Path.Combine(dir, "example-theme.json");
var resourceStream = typeof(Plugin).Assembly.GetManifestResourceStream(
"HellionChat.Themes.Builtin.example-theme.json"
);
if (resourceStream is null)
{
Log.Warning("Themes example template not found in assembly resources; skipping seed.");
return;
}
try
{
using var fileStream = File.Create(examplePath);
resourceStream.CopyTo(fileStream);
Log.Information($"Seeded example-theme.json into {dir}");
}
catch (IOException ex)
{
Log.Warning(
ex,
"Failed to seed example-theme.json; user can create custom themes manually."
);
}
finally
{
resourceStream.Dispose();
}
}
}