Files
HellionChat/HellionChat/Themes/ThemeRegistry.cs
T
JonKazama-Hellion 54ff88d6d4 refactor(di): migrate Root + Misc to ILogger<T> (DI-4 Slice D)
Slice D shrinks vs the original plan: three of the six files cannot
take an ILogger ctor arg without breaking external contracts.

Migrated (8 LogProxy sites across 4 files):
- Commands: 2 sites (Warning, Error). New ctor takes ILogger<Commands>.
- Themes/ThemeRegistry: 1 site (Debug). ILogger<ThemeRegistry>? is
  optional (default null) so the existing Build-Suite tests that
  construct `new ThemeRegistry()` parameterless keep working without
  changes. _logger?.LogDebug guards the call site.
- PayloadHandler: 3 sites (Error, Warning, Error). New ctor takes
  ILogger<PayloadHandler>. ChatLogWindow's two `new PayloadHandler(this)`
  sites (the direct field and the Lender lambda) now hand a fresh
  CreateLogger<PayloadHandler>() from the existing _loggerFactory.

Not migrated (5 sites stay on Plugin.LogProxy, plan drifts D12-D14):
- D12 - Configuration (1 site): IPluginConfiguration, instantiated by
  Dalamud's Interface.GetPluginConfig() via reflection on the
  parameterless ctor. Adding an ILogger arg would break GetPluginConfig.
- D13 - Message (4 sites): partial data class with two ctor overloads,
  mass-instantiated across 3 plugin sites plus Newtonsoft JSON
  deserialisation. Ctor extension would be invasive across ~20 call
  sites with low payoff (data-class logger is unusual).
- D14 - FontManager (2 sites): both Plugin.LogProxy calls live in
  static methods (TryGetHellionFontBytes, AddFontWithFallback) that
  cannot reach an instance _logger. Same root cause as D8 in
  GameFunctions. FontManager joins the static-bucket alongside
  EmoteCache et al.; the ctor + _logger field added mid-Slice-D were
  rolled back to keep the class clean.

Plugin.LogProxy surface after C9 (8 file buckets, ~12 sites total):
- 4 originally-static consumers: EmoteCache, AutoTranslate,
  MemoryUtil, WrapperUtil
- 3 cannot-take-ctor-arg consumers: Configuration, Message, FontManager
- 1 single-static-method consumer: GameFunctions.TryOpenAdventurerPlate
  (D8 from Slice B)

Smoke 2 is now due.
2026-05-17 11:02:08 +02:00

230 lines
8.8 KiB
C#

using HellionChat.Themes.Builtin;
using Microsoft.Extensions.Logging;
namespace HellionChat.Themes;
public sealed class ThemeRegistry
{
private readonly ILogger<ThemeRegistry>? _logger;
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
);
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, ILogger<ThemeRegistry>? logger = null)
{
_logger = logger;
// Insertion order drives the Theme-Picker grid layout (3 columns).
// Row 1: blue family. Row 2: purple to magenta family.
// Row 3: green / warm / classic. Row 4: Synthwave Sunset as a
// retro bonus on its own line.
_builtIns = new Dictionary<string, Theme>(StringComparer.OrdinalIgnoreCase)
{
{ HellionArctic.Slug, HellionArctic.Build() },
{ HellionSpectrum.Slug, HellionSpectrum.Build() },
{ NightBlue.Slug, NightBlue.Build() },
{ EventHorizon.Slug, EventHorizon.Build() },
{ IndigoViolet.Slug, IndigoViolet.Build() },
{ CrystalNocturne.Slug, CrystalNocturne.Build() },
{ MintGrove.Slug, MintGrove.Build() },
{ ForgeMerchantman.Slug, ForgeMerchantman.Build() },
{ Chat2Classic.Slug, Chat2Classic.Build() },
{ SynthwaveSunset.Slug, SynthwaveSunset.Build() },
};
// Centralised so Build() factories stay free of cache plumbing.
foreach (var theme in _builtIns.Values)
theme.RecomputeAbgrCache();
_active = _builtIns[DefaultSlug];
_customThemesDir = customThemesDir;
}
public Theme Active => _active;
public Theme Get(string slug)
{
if (_builtIns.TryGetValue(slug, out var b))
return b;
// 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;
return _builtIns[DefaultSlug];
}
public IEnumerable<Theme> AllBuiltIns() => _builtIns.Values;
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)
{
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.
// Other IO failures are permanent — theme is dropped instead of retried.
internal static bool IsRecoverableFileLock(Exception? ex)
{
if (ex is not IOException io)
return false;
var code = (uint)io.HResult;
return code == 0x80070020u || code == 0x80070021u;
}
// 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 kvp in _customCache)
{
if (string.Equals(kvp.Value.Theme.Slug, slug, StringComparison.OrdinalIgnoreCase))
{
sourcePath = kvp.Key;
return kvp.Value.Theme;
}
}
return null;
}
private IEnumerable<Theme> RefreshCustomCache()
{
if (_customThemesDir is null || !Directory.Exists(_customThemesDir))
yield break;
var seenSlugs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in Directory.EnumerateFiles(_customThemesDir, "*.json"))
{
Theme? theme = null;
var stamp = File.GetLastWriteTimeUtc(path);
var key = path;
if (_customCache.TryGetValue(key, out var cached) && cached.Stamp == stamp)
{
theme = cached.Theme;
}
else
{
try
{
theme = ThemeJsonLoader.LoadFromFile(path);
theme.RecomputeAbgrCache();
_customCache[key] = (theme, stamp);
}
catch (Exception ex) when (IsRecoverableFileLock(ex))
{
// Editor mid-save: keep last known good, retry on next refresh.
_logger?.LogDebug(
$"Custom theme {Path.GetFileName(path)} is locked, keeping last known good"
);
if (cached.Theme is not null)
theme = cached.Theme;
}
catch (Exception)
{
continue;
}
}
if (theme is not null && seenSlugs.Add(theme.Slug))
yield return theme;
}
}
}