diff --git a/HellionChat/Themes/ThemeRegistry.cs b/HellionChat/Themes/ThemeRegistry.cs index c0e70b4..cbac2c1 100644 --- a/HellionChat/Themes/ThemeRegistry.cs +++ b/HellionChat/Themes/ThemeRegistry.cs @@ -32,6 +32,16 @@ public sealed class ThemeRegistry 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? logger = null) { _logger = logger; @@ -87,6 +97,13 @@ public sealed class ThemeRegistry // 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; @@ -115,6 +132,84 @@ public sealed class ThemeRegistry _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