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:
@@ -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,
|
||||
|
||||
@@ -6,6 +6,13 @@ public sealed class ThemeRegistry
|
||||
{
|
||||
public const string DefaultSlug = HellionArctic.Slug;
|
||||
|
||||
// 1Hz throttle for the v1.4.8 B2 auto-refresh-on-active path. The
|
||||
// Plugin.Draw hook calls RefreshActiveIfStale every frame, but the
|
||||
// actual File.GetLastWriteTimeUtc disk-stat only runs once per second
|
||||
// -- 60fps would otherwise mean 3600 stats/min on the same path (more
|
||||
// on Wine). Same idiom as the StatusBar 1Hz cache.
|
||||
private const long ActiveStampPollIntervalMs = 1000;
|
||||
|
||||
private readonly Dictionary<string, Theme> _builtIns;
|
||||
private readonly Dictionary<string, (Theme Theme, DateTime Stamp)> _customCache = new(
|
||||
StringComparer.OrdinalIgnoreCase
|
||||
@@ -13,6 +20,15 @@ public sealed class ThemeRegistry
|
||||
private readonly string? _customThemesDir;
|
||||
private Theme _active;
|
||||
|
||||
// v1.4.8 B2: source path of the currently active custom theme. Captured
|
||||
// at Switch() time so RefreshActiveIfStale does not have to reconstruct
|
||||
// a filename from the slug -- custom theme filenames are not required
|
||||
// to match the slug they declare in the JSON body. Null when the active
|
||||
// theme is built-in or no custom-themes directory is configured.
|
||||
private string? _activeCustomPath;
|
||||
private long _lastActiveStampCheckMs = -ActiveStampPollIntervalMs;
|
||||
private DateTime _lastActiveStamp = DateTime.MinValue;
|
||||
|
||||
public ThemeRegistry(string? customThemesDir = null)
|
||||
{
|
||||
// Insertion order drives the Theme-Picker grid layout (3 columns).
|
||||
@@ -48,7 +64,9 @@ public sealed class ThemeRegistry
|
||||
if (_builtIns.TryGetValue(slug, out var b))
|
||||
return b;
|
||||
|
||||
var custom = LoadCustomBySlug(slug);
|
||||
// Discard the source path here; Switch is the only call-site that
|
||||
// needs to remember it for the auto-refresh hook.
|
||||
var custom = LoadCustomBySlug(slug, out _);
|
||||
if (custom != null)
|
||||
return custom;
|
||||
|
||||
@@ -59,12 +77,70 @@ public sealed class ThemeRegistry
|
||||
|
||||
public IEnumerable<Theme> AllCustom() => RefreshCustomCache();
|
||||
|
||||
// Built-in-first to match Get(slug)'s lookup order. A user theme JSON
|
||||
// that declares the same slug as a built-in is ignored deliberately --
|
||||
// having Switch prefer custom and Get prefer built-in would produce
|
||||
// a state where _active and Get(_active.Slug) disagree.
|
||||
public void Switch(string slug)
|
||||
{
|
||||
var theme = Get(slug);
|
||||
// Defensive — ensures any future theme source always gets a populated cache.
|
||||
theme.RecomputeAbgrCache();
|
||||
_active = theme;
|
||||
if (_builtIns.TryGetValue(slug, out var builtin))
|
||||
{
|
||||
_active = builtin;
|
||||
_active.RecomputeAbgrCache();
|
||||
_activeCustomPath = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var customTheme = LoadCustomBySlug(slug, out var customPath);
|
||||
if (customTheme is not null)
|
||||
{
|
||||
_active = customTheme;
|
||||
// Defensive — ensures any future theme source always gets a populated cache.
|
||||
_active.RecomputeAbgrCache();
|
||||
_activeCustomPath = customPath;
|
||||
// Force a first-tick reload-check after the switch so the stamp
|
||||
// baseline is established on the next RefreshActiveIfStale call.
|
||||
_lastActiveStamp = DateTime.MinValue;
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: neither built-in nor custom matched. Drop to default
|
||||
// and clear the active custom path so RefreshActiveIfStale stays idle.
|
||||
_active = _builtIns[DefaultSlug];
|
||||
_active.RecomputeAbgrCache();
|
||||
_activeCustomPath = null;
|
||||
}
|
||||
|
||||
// 1Hz-throttled disk-stat on the currently active custom theme file.
|
||||
// When the file's LastWriteTime moves forward (editor save), reload the
|
||||
// theme via Get() so the user sees the edit immediately without
|
||||
// re-selecting in the picker. Built-in themes short-circuit; custom
|
||||
// themes without an _activeCustomPath (e.g. Switch fell to default)
|
||||
// short-circuit too.
|
||||
public void RefreshActiveIfStale()
|
||||
{
|
||||
var now = Environment.TickCount64;
|
||||
if (now - _lastActiveStampCheckMs < ActiveStampPollIntervalMs)
|
||||
return;
|
||||
_lastActiveStampCheckMs = now;
|
||||
|
||||
if (_active.IsBuiltIn)
|
||||
return;
|
||||
|
||||
var path = _activeCustomPath;
|
||||
if (path is null || !File.Exists(path))
|
||||
return;
|
||||
|
||||
var stamp = File.GetLastWriteTimeUtc(path);
|
||||
if (!ThemeStampDiff.IsStale(_lastActiveStamp, stamp))
|
||||
return;
|
||||
_lastActiveStamp = stamp;
|
||||
|
||||
// Get() re-runs RefreshCustomCache which picks up the new content
|
||||
// (the cache keys by path + LastWriteTime, so a mtime bump invalidates).
|
||||
// RecomputeAbgrCache happens inside RefreshCustomCache on cache miss.
|
||||
var reloaded = Get(_active.Slug);
|
||||
_active = reloaded;
|
||||
}
|
||||
|
||||
// 0x80070020 = SHARING_VIOLATION, 0x80070021 = LOCK_VIOLATION.
|
||||
@@ -77,18 +153,30 @@ public sealed class ThemeRegistry
|
||||
return code == 0x80070020u || code == 0x80070021u;
|
||||
}
|
||||
|
||||
// Custom themes are loaded lazily, cached by LastWriteTime.
|
||||
// A changed JSON is reloaded on the next lookup.
|
||||
private Theme? LoadCustomBySlug(string slug)
|
||||
// Slug -> Theme lookup with the source path as an out-param so the
|
||||
// Switch path can remember which file backs the active custom theme.
|
||||
// Pure reverse-lookup over the existing _customCache: that cache is
|
||||
// already Path -> (Theme, Stamp), so iterating it costs nothing,
|
||||
// avoids a re-parse of every JSON, and keeps the parse logic (and
|
||||
// the recoverable-file-lock recovery) confined to RefreshCustomCache.
|
||||
// The cache must be warm before this runs; Plugin.LoadAsync triggers
|
||||
// a one-time warm-up via AllCustom() before the first Switch call.
|
||||
private Theme? LoadCustomBySlug(string slug, out string? sourcePath)
|
||||
{
|
||||
sourcePath = null;
|
||||
if (_customThemesDir is null)
|
||||
return null;
|
||||
if (!Directory.Exists(_customThemesDir))
|
||||
return null;
|
||||
|
||||
foreach (var theme in RefreshCustomCache())
|
||||
if (string.Equals(theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
|
||||
return theme;
|
||||
foreach (var kvp in _customCache)
|
||||
{
|
||||
if (string.Equals(kvp.Value.Theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sourcePath = kvp.Key;
|
||||
return kvp.Value.Theme;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace HellionChat.Themes;
|
||||
|
||||
// Pure stale-check for the v1.4.8 B2 theme-auto-refresh-on-active path.
|
||||
// Lives in a free helper class so the Build-Suite can exercise the diff
|
||||
// rules without instantiating ThemeRegistry (which touches the Dalamud
|
||||
// log proxy and the filesystem). The rules:
|
||||
// - DateTime.MinValue on the current stat means we could not read the
|
||||
// file -- hold the last known good (return false).
|
||||
// - Equal stamps mean no change since we last saw it.
|
||||
// - Any other difference, including the first observation where lastSeen
|
||||
// is MinValue, counts as stale and triggers a reload.
|
||||
internal static class ThemeStampDiff
|
||||
{
|
||||
public static bool IsStale(System.DateTime lastSeen, System.DateTime current)
|
||||
{
|
||||
if (current == System.DateTime.MinValue)
|
||||
return false;
|
||||
return current != lastSeen;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user