// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.WinUI;
using ManagedCommon;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.UI.ViewManagement;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
namespace Microsoft.CmdPal.UI.Services;
///
/// ThemeService is a hub that translates user settings and system preferences into concrete
/// theme resources and notifies listeners of changes.
///
internal sealed partial class ThemeService : IThemeService, IDisposable
{
private static readonly TimeSpan ReloadDebounceInterval = TimeSpan.FromMilliseconds(500);
private readonly UISettings _uiSettings;
private readonly SettingsModel _settings;
private readonly ResourceSwapper _resourceSwapper;
private readonly NormalThemeProvider _normalThemeProvider;
private readonly ColorfulThemeProvider _colorfulThemeProvider;
private DispatcherQueue? _dispatcherQueue;
private DispatcherQueueTimer? _dispatcherQueueTimer;
private bool _isInitialized;
private bool _disposed;
private InternalThemeState _currentState;
public event EventHandler? ThemeChanged;
public ThemeSnapshot Current => Volatile.Read(ref _currentState).Snapshot;
///
/// Initializes the theme service. Must be called after the application window is activated and on UI thread.
///
public void Initialize()
{
if (_isInitialized)
{
return;
}
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
if (_dispatcherQueue is null)
{
throw new InvalidOperationException("Failed to get DispatcherQueue for the current thread. Ensure Initialize is called on the UI thread after window activation.");
}
_dispatcherQueueTimer = _dispatcherQueue.CreateTimer();
_resourceSwapper.Initialize();
_isInitialized = true;
Reload();
}
private void Reload()
{
if (!_isInitialized)
{
return;
}
// provider selection
var intensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100);
IThemeProvider provider = intensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image
? _colorfulThemeProvider
: _normalThemeProvider;
// Calculate values
var tint = _settings.ColorizationMode switch
{
ColorizationMode.CustomColor => _settings.CustomThemeColor,
ColorizationMode.WindowsAccentColor => _uiSettings.GetColorValue(UIColorType.Accent),
ColorizationMode.Image => _settings.CustomThemeColor,
_ => Colors.Transparent,
};
var effectiveTheme = GetElementTheme((ElementTheme)_settings.Theme);
var imageSource = _settings.ColorizationMode == ColorizationMode.Image
? LoadImageSafe(_settings.BackgroundImagePath)
: null;
var stretch = _settings.BackgroundImageFit switch
{
BackgroundImageFit.Fill => Stretch.Fill,
_ => Stretch.UniformToFill,
};
var opacity = Math.Clamp(_settings.BackgroundImageOpacity, 0, 100) / 100.0;
// create context and offload to actual theme provider
var context = new ThemeContext
{
Tint = tint,
ColorIntensity = intensity,
Theme = effectiveTheme,
BackgroundImageSource = imageSource,
BackgroundImageStretch = stretch,
BackgroundImageOpacity = opacity,
};
var backdrop = provider.GetAcrylicBackdrop(context);
var blur = _settings.BackgroundImageBlurAmount;
var brightness = _settings.BackgroundImageBrightness;
// Create public snapshot (no provider!)
var snapshot = new ThemeSnapshot
{
Tint = tint,
TintIntensity = intensity / 100f,
Theme = effectiveTheme,
BackgroundImageSource = imageSource,
BackgroundImageStretch = stretch,
BackgroundImageOpacity = opacity,
BackdropParameters = backdrop,
BlurAmount = blur,
BackgroundBrightness = brightness / 100f,
};
// Bundle with provider for internal use
var newState = new InternalThemeState
{
Snapshot = snapshot,
Provider = provider,
};
// Atomic swap
Interlocked.Exchange(ref _currentState, newState);
_resourceSwapper.TryActivateTheme(provider.ThemeKey);
ThemeChanged?.Invoke(this, new ThemeChangedEventArgs());
}
private static BitmapImage? LoadImageSafe(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
// If it looks like a file path and exists, prefer absolute file URI
if (!Uri.TryCreate(path, UriKind.RelativeOrAbsolute, out var uri))
{
return null;
}
if (!uri.IsAbsoluteUri && File.Exists(path))
{
uri = new Uri(Path.GetFullPath(path));
}
return new BitmapImage(uri);
}
catch (Exception ex)
{
Logger.LogWarning($"Failed to load background image '{path}'. {ex.Message}");
return null;
}
}
public ThemeService(SettingsModel settings, ResourceSwapper resourceSwapper)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(resourceSwapper);
_settings = settings;
_settings.SettingsChanged += SettingsOnSettingsChanged;
_resourceSwapper = resourceSwapper;
_uiSettings = new UISettings();
_uiSettings.ColorValuesChanged += UiSettings_ColorValuesChanged;
_normalThemeProvider = new NormalThemeProvider(_uiSettings);
_colorfulThemeProvider = new ColorfulThemeProvider(_uiSettings);
List providers = [_normalThemeProvider, _colorfulThemeProvider];
foreach (var provider in providers)
{
_resourceSwapper.RegisterTheme(provider.ThemeKey, provider.ResourcePath);
}
_currentState = new InternalThemeState
{
Snapshot = new ThemeSnapshot
{
Tint = Colors.Transparent,
Theme = ElementTheme.Light,
BackdropParameters = new AcrylicBackdropParameters(Colors.Black, Colors.Black, 0.5f, 0.5f),
BackgroundImageOpacity = 1,
BackgroundImageSource = null,
BackgroundImageStretch = Stretch.Fill,
BlurAmount = 0,
TintIntensity = 1.0f,
BackgroundBrightness = 0,
},
Provider = _normalThemeProvider,
};
}
private void RequestReload()
{
if (!_isInitialized || _dispatcherQueueTimer is null)
{
return;
}
_dispatcherQueueTimer.Debounce(Reload, ReloadDebounceInterval);
}
private ElementTheme GetElementTheme(ElementTheme theme)
{
return theme switch
{
ElementTheme.Light => ElementTheme.Light,
ElementTheme.Dark => ElementTheme.Dark,
_ => _uiSettings.GetColorValue(UIColorType.Background).CalculateBrightness() < 0.5
? ElementTheme.Dark
: ElementTheme.Light,
};
}
private void SettingsOnSettingsChanged(SettingsModel sender, object? args)
{
RequestReload();
}
private void UiSettings_ColorValuesChanged(UISettings sender, object args)
{
RequestReload();
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_dispatcherQueueTimer?.Stop();
_uiSettings.ColorValuesChanged -= UiSettings_ColorValuesChanged;
_settings.SettingsChanged -= SettingsOnSettingsChanged;
}
private sealed class InternalThemeState
{
public required ThemeSnapshot Snapshot { get; init; }
public required IThemeProvider Provider { get; init; }
}
}