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.
This commit is contained in:
2026-05-13 23:22:14 +02:00
parent 2c64aaa251
commit 74bcb91b65
3 changed files with 131 additions and 11 deletions
+12
View File
@@ -228,6 +228,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
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();
@@ -737,6 +743,12 @@ public sealed class Plugin : IAsyncDalamudPlugin
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,