diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index 7263120..e799129 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -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, diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index 90f0135..4649bec 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -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 _builtIns; private readonly Dictionary _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 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; } diff --git a/HellionChat/Themes/ThemeStampDiff.cs b/HellionChat/Themes/ThemeStampDiff.cs new file mode 100644 index 0000000..aa59a2d --- /dev/null +++ b/HellionChat/Themes/ThemeStampDiff.cs @@ -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; + } +}