74b07519f5
Three new private fields plus TryGetActiveCrossfade entry-point, plus SwitchSilent variant for the plugin-load init path. ArmCrossfade captures a value-copy of the active AbgrCache and stamps TickCount64; mid-crossfade Switch composes the current lerped state as the next fade origin so back-to-back theme switches stay smooth. Same-slug Switch is a no-op (no identity-crossfade).
325 lines
12 KiB
C#
325 lines
12 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;
|
|
|
|
// PM-1 crossfade state. Switch() captures the previous AbgrCache as a
|
|
// VALUE-COPY (not a Theme reference) -- the built-in singletons share
|
|
// their RecomputeAbgrCache identity, so a reference would mutate
|
|
// alongside the new active. _crossfadeStartTickMs == long.MinValue
|
|
// means "no crossfade armed yet"; the field stays MinValue after
|
|
// SwitchSilent so the plugin-load init-path does not trigger a fade.
|
|
private ThemeAbgrCache? _previousAbgrSnapshot;
|
|
private long _crossfadeStartTickMs = long.MinValue;
|
|
private const int CrossfadeDurationMs = 300;
|
|
|
|
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)
|
|
{
|
|
// Same-slug switch is a no-op -- avoids a 300ms identity-crossfade
|
|
// when the user re-selects the active theme in the picker.
|
|
if (string.Equals(_active.Slug, slug, StringComparison.OrdinalIgnoreCase))
|
|
return;
|
|
|
|
ArmCrossfade();
|
|
|
|
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;
|
|
}
|
|
|
|
// SwitchSilent is the plugin-load init path -- identical to Switch
|
|
// but does NOT arm the crossfade state. Called from
|
|
// ThemeRegistryInitHostedService.StartAsync so opening the plugin
|
|
// does not produce a 300ms fade from the default theme to the user's
|
|
// saved theme.
|
|
public void SwitchSilent(string slug)
|
|
{
|
|
if (string.Equals(_active.Slug, slug, StringComparison.OrdinalIgnoreCase))
|
|
return;
|
|
|
|
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;
|
|
_active.RecomputeAbgrCache();
|
|
_activeCustomPath = customPath;
|
|
_lastActiveStamp = DateTime.MinValue;
|
|
return;
|
|
}
|
|
|
|
_active = _builtIns[DefaultSlug];
|
|
_active.RecomputeAbgrCache();
|
|
_activeCustomPath = null;
|
|
}
|
|
|
|
// Captures the AbgrCache snapshot that PushGlobal should fade FROM.
|
|
// If a crossfade is already mid-flight (second Switch within 300ms),
|
|
// the current lerped state replaces the snapshot -- the next fade
|
|
// starts from where we currently are, not from the original "from".
|
|
private void ArmCrossfade()
|
|
{
|
|
var now = Environment.TickCount64;
|
|
ThemeAbgrCache snapshot;
|
|
if (
|
|
_previousAbgrSnapshot.HasValue
|
|
&& _crossfadeStartTickMs != long.MinValue
|
|
&& now - _crossfadeStartTickMs < CrossfadeDurationMs
|
|
)
|
|
{
|
|
var t = (float)(now - _crossfadeStartTickMs) / CrossfadeDurationMs;
|
|
snapshot = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, t);
|
|
}
|
|
else
|
|
{
|
|
snapshot = _active.AbgrCache;
|
|
}
|
|
|
|
_previousAbgrSnapshot = snapshot;
|
|
_crossfadeStartTickMs = now;
|
|
}
|
|
|
|
// Returns the lerped AbgrCache while the crossfade is active.
|
|
// PushGlobal reads this once per frame; outside the 300ms window
|
|
// it short-circuits via the TickCount64 delta so the per-frame
|
|
// overhead is a couple of integer comparisons.
|
|
public bool TryGetActiveCrossfade(out ThemeAbgrCache lerped)
|
|
{
|
|
lerped = default;
|
|
if (_crossfadeStartTickMs == long.MinValue || !_previousAbgrSnapshot.HasValue)
|
|
return false;
|
|
|
|
var elapsed = Environment.TickCount64 - _crossfadeStartTickMs;
|
|
if (elapsed >= CrossfadeDurationMs)
|
|
return false;
|
|
|
|
var t = (float)elapsed / CrossfadeDurationMs;
|
|
lerped = ThemeAbgrCacheLerp.Lerp(_previousAbgrSnapshot.Value, _active.AbgrCache, t);
|
|
return true;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|