using System.Diagnostics; using System.IO; using System.Reflection; using System.Windows.Media.Animation; namespace Melskin.Appearance { /// /// 统一主题管理(合并原 ThemeManager + AppearanceManager 功能)。 /// 功能点: /// 1. 维护当前明暗模式 / 调色板缓存与事件 /// 2. 提供异步/同步切换(含可选动画) /// 3. 动画策略:先交换字典(保证新增键立即可用)再对所有可动画 SolidColorBrush 做颜色补间(含新增键过渡) /// 4. 只允许同时存在一个主题字典 + 一个调色板字典(顺序:主题 -> 调色板 -> 其它) /// 5. 可选持久化 /// 兼容:保留旧 API (ApplyThemeMode / ApplyThemePalette / SwitchThemeMode 等) /// /// 设计要点: /// - “主题”与“调色板”被拆成两个 ResourceDictionary;顺序控制保证覆盖链正确。 /// - 动画采用“字典快速替换 + 颜色补间”而不是实时动态更新键值,确保资源引用立即可见且减少闪烁。 /// - AnimatableBrushKeys 只跟踪 SolidColorBrush 键(避免对非 Brush / 冻结 Freezable 执行动画引发异常)。 /// - 使用 Dispatcher 保障在 UI 线程执行所有 WPF 资源操作。 /// - 同步 API 内部通过 DispatcherFrame 等待异步完成(避免外部 async 污染,同时保持阻塞式调用语义)。 /// public class ThemeManager { /// /// 获取当前程序集的命名空间名称,该属性返回一个字符串,表示包含ThemeManager类的程序集的名称。 /// /// /// 该属性用于获取应用程序的命名空间,以便在构建资源路径或其他需要引用程序集名称的地方使用。例如,在定位主题字典路径或着色器文件时,`LibraryNamespace`提供了必要的程序集名称信息。 /// public static string LibraryNamespace => Assembly.GetExecutingAssembly().GetName().Name!; /// /// 获取主题字典的基路径,该路径用于定位应用程序中的主题资源文件。此属性返回一个字符串,表示到主题资源文件夹的包URI。 /// /// /// 该属性通常被用于构建指向具体主题样式或颜色调色板文件的完整路径。例如,它可用于加载位于`Themes/`目录下的不同主题模式(如Light.xaml, Dark.xaml)或颜色调色板文件(如Accents/LightBlue.xaml)。通过使用`ThemesDictionaryPath`作为基础路径,可以方便地访问和应用不同的主题设置。 /// public static string ThemesDictionaryPath => $"pack://application:,,,/{LibraryNamespace};component/Themes/"; private static bool _persistenceEnabled; /// /// 当应用程序的主题模式发生变化时触发的事件。此事件允许订阅者在主题模式更改时执行特定的操作。 /// /// /// 该事件通常在调用改变主题模式的方法(如ApplyThemeMode或SwitchThemeMode)后被触发。通过注册此事件,开发者可以监听到主题模式的变化,并根据需要更新UI或其他逻辑。 /// public static event Action? ThemeModeChanged; /// /// 当应用程序的主题调色板发生变化时触发的事件。此事件允许订阅者在主题调色板更改时执行特定操作。 /// /// /// 该事件通常用于通知UI组件或其他依赖于当前主题调色板的服务,以便它们可以相应地更新其外观或行为。每当通过`ApplyTheme`、`ApplyThemePalette`等方法改变主题调色板时,都会触发此事件,并传递新的`ThemePalette`作为参数给所有注册的处理程序。 /// public static event Action? ThemePaletteChanged; private static readonly IEasingFunction ThemeEasing = new CubicEase { EasingMode = EasingMode.EaseInOut }; #region Animatable Keys private static readonly object KeyLock = new(); private static List _animatableBrushKeys = []; private static IReadOnlyList AnimatableBrushKeys { get { lock (KeyLock) return [.. _animatableBrushKeys]; } } private static void RefreshAnimatableKeysFromCurrentManagedDictionaries(ResourceDictionary root, bool merge = false) { var keys = new HashSet(StringComparer.Ordinal); if (merge) { lock (KeyLock) { foreach (var k in _animatableBrushKeys) keys.Add(k); } } foreach (var dict in root.MergedDictionaries.Where(IsManagedThemeDictionary)) { foreach (var key in dict.Keys.OfType()) { if (dict[key] is SolidColorBrush) keys.Add(key.ToString()!); } } lock (KeyLock) { _animatableBrushKeys = [.. keys]; } } private static Dictionary TakeColorSnapshot(ResourceDictionary resources) { var snapshot = new Dictionary(StringComparer.Ordinal); foreach (var key in AnimatableBrushKeys) { var brush = FindBrushInManagedDictionaries(resources, key); if (brush != null) snapshot[key] = brush.Color; } return snapshot; } private static SolidColorBrush? FindBrushInManagedDictionaries(ResourceDictionary root, string key) { foreach (var d in root.MergedDictionaries.Where(IsManagedThemeDictionary)) { if (d.Contains(key) && d[key] is SolidColorBrush b) return b; } return null; } private static bool IsManagedThemeDictionary(ResourceDictionary dict) { var src = dict.Source?.OriginalString; if (string.IsNullOrEmpty(src)) return false; if (src?.IndexOf("/Accents/", StringComparison.OrdinalIgnoreCase) >= 0) return true; var fileName = Path.GetFileName(src); return fileName != null && (fileName.Equals("Light.xaml", StringComparison.OrdinalIgnoreCase) || fileName.Equals("Dark.xaml", StringComparison.OrdinalIgnoreCase)); } private static Task AnimateToNewTheme(ResourceDictionary resources, Dictionary fromColors, int baseDurationMs) { var tasks = new List(); foreach (var dict in resources.MergedDictionaries.Where(IsManagedThemeDictionary).ToList()) { foreach (var keyObj in dict.Keys.OfType().ToList()) { var key = keyObj?.ToString(); if (key == null) continue; if (!AnimatableBrushKeys.Contains(key)) continue; if (dict[key] is not SolidColorBrush brush) continue; var toColor = brush.Color; if (!fromColors.TryGetValue(key, out var fromColor)) fromColor = Color.FromArgb(0, toColor.R, toColor.G, toColor.B); if (toColor == fromColor) continue; Debug.WriteLine($"Animate Key: {key} from {fromColor} to {toColor}"); if (brush.IsFrozen) { // 冻结的 Brush 无法动画,需要复制一个新的 brush = brush.Clone(); dict[key] = brush; // 用可写的克隆体替换 } // 设置初始色,然后异步补间到目标色 brush.Color = fromColor; tasks.Add(AnimateBrushColorAsync(brush, toColor, baseDurationMs)); } } return Task.WhenAll(tasks); } /// /// 【核心修改】针对单个 Brush 的颜色动画封装,在Completed事件中捕获异常。 /// private static Task AnimateBrushColorAsync(SolidColorBrush brush, Color toColor, int durationMs) { var tcs = new TaskCompletionSource(); if (brush.IsFrozen) { tcs.SetResult(true); return tcs.Task; } var animation = new ColorAnimation { To = toColor, Duration = TimeSpan.FromMilliseconds(durationMs), EasingFunction = ThemeEasing, FillBehavior = FillBehavior.Stop }; animation.Completed += (_, _) => { // 这是最关键的地方:我们用try-catch包围了可能失败的操作 try { // 尝试移除动画并固化最终颜色 brush.BeginAnimation(SolidColorBrush.ColorProperty, null); brush.Color = toColor; } catch (InvalidOperationException) { // 异常被预料并捕获。 // 此时动画已播放完毕,虽然固化失败,但因ResourceDictionary已切换,UI最终状态正确。 // 我们安全地忽略这个异常,不让程序崩溃。 } finally { // 无论成功还是失败,都通知Task已完成。 tcs.TrySetResult(true); } }; brush.BeginAnimation(SolidColorBrush.ColorProperty, animation); return tcs.Task; } #endregion #region Public Query /// /// 获取当前应用程序的主题模式。 /// /// 返回当前应用的主题模式,可能的值为 Light 或 Dark。 public static ThemeMode GetAppThemeMode() { var dict = Current.Resources.MergedDictionaries .FirstOrDefault(d => d.Source != null && (d.Source.OriginalString.EndsWith("light.xaml", StringComparison.OrdinalIgnoreCase) || d.Source.OriginalString.EndsWith("dark.xaml", StringComparison.OrdinalIgnoreCase))); if (dict?.Source != null) { var s = dict.Source.OriginalString; if (s.EndsWith("light.xaml", StringComparison.OrdinalIgnoreCase)) ActiveThemeMode = ThemeMode.Light; else if (s.EndsWith("dark.xaml", StringComparison.OrdinalIgnoreCase)) ActiveThemeMode = ThemeMode.Dark; } return ActiveThemeMode; } /// /// 获取应用程序当前使用的主题调色板。 /// /// 返回当前激活的主题调色板,类型为ThemePalette枚举。 public static ThemePalette GetAppThemePalette() { var dict = Current.Resources.MergedDictionaries .FirstOrDefault(d => d.Source != null && d.Source.OriginalString.IndexOf("/Accents/", StringComparison.OrdinalIgnoreCase) >= 0); if (dict?.Source != null) { var s = Path.GetFileNameWithoutExtension(dict.Source.OriginalString); if (s.EndsWith("Blue", StringComparison.OrdinalIgnoreCase)) ActiveThemePalette = ThemePalette.Blue; else if (s.EndsWith("Green", StringComparison.OrdinalIgnoreCase)) ActiveThemePalette = ThemePalette.Green; else if (s.EndsWith("Purple", StringComparison.OrdinalIgnoreCase)) ActiveThemePalette = ThemePalette.Purple; } return ActiveThemePalette; } #endregion #region Apply Resources To Element /// /// 将应用程序级别的资源应用到指定的 FrameworkElement 上。 /// /// 要应用资源的 FrameworkElement。 public static void ApplyResourcesToElement(FrameworkElement frameworkElement) { if (frameworkElement == null) return; var appMerged = Current.Resources.MergedDictionaries; var target = frameworkElement.Resources.MergedDictionaries; if (target.Count < appMerged.Count) { foreach (var d in appMerged) if (!target.Contains(d)) target.Add(d); } } #endregion #region Unified Theme API (Async) /// /// 【核心功能】应用指定的主题模式和调色板,支持动画过渡。 /// /// 可选参数,指定主题模式(浅色、深色或系统默认)。 /// 可选参数,指定主题调色板(天蓝、墨绿或浅紫)。 /// 布尔值,指示是否启用动画过渡效果。 /// 整数,表示动画持续时间(毫秒),默认为350毫秒。 /// 返回一个Task对象,代表异步操作的完成状态。 public static Task ApplyThemeAsync(ThemeMode? mode = null, ThemePalette? palette = null, bool animate = true, int animationDurationMs = 350) => InternalApplyThemeAsync(mode, palette, animate, animationDurationMs); /// /// 应用指定的主题模式和调色板,可以选择是否进行动画过渡。 /// /// 可选参数,指定要应用的主题模式(浅色、深色或系统默认)。如果为null,则使用当前设置。 /// 可选参数,指定要应用的主题调色板。如果为null,则使用当前设置。 /// 布尔值,指示主题更改时是否应带有动画效果。默认为false。 /// 整数,指定动画持续时间(毫秒)。仅当animate为true时有效,默认值为350毫秒。 public static void ApplyTheme(ThemeMode? mode = null, ThemePalette? palette = null, bool animate = false, int animationDurationMs = 350) { var task = InternalApplyThemeAsync(mode, palette, animate, animationDurationMs); // 如果在UI线程,使用DispatcherFrame等待,避免死锁 if (Application.Current?.Dispatcher.CheckAccess() == true && !task.IsCompleted) { var frame = new System.Windows.Threading.DispatcherFrame(); task.ContinueWith(_ => frame.Continue = false, TaskScheduler.FromCurrentSynchronizationContext()); System.Windows.Threading.Dispatcher.PushFrame(frame); } task.GetAwaiter().GetResult(); // 确保任何异常都能被抛出 } private static async Task InternalApplyThemeAsync(ThemeMode? mode, ThemePalette? palette, bool animate, int durationMs) { if (Application.Current?.Dispatcher == null) return; if (!Application.Current.Dispatcher.CheckAccess()) { await Application.Current.Dispatcher.InvokeAsync( () => InternalApplyThemeAsync(mode, palette, animate, durationMs)); return; } var currentMode = GetAppThemeMode(); var currentPalette = GetAppThemePalette(); var newMode = mode ?? currentMode; var newPalette = palette ?? currentPalette; bool modeChanged = newMode != currentMode; bool paletteChanged = newPalette != currentPalette || modeChanged; if (!modeChanged && !paletteChanged) return; var appResources = Current.Resources; Dictionary? fromColors = null; if (animate) { RefreshAnimatableKeysFromCurrentManagedDictionaries(appResources, merge: false); fromColors = TakeColorSnapshot(appResources); } string modeName = newMode == ThemeMode.Dark ? "Dark" : "Light"; string paletteName = newPalette.ToString(); var themeUri = new Uri($"{ThemesDictionaryPath}{modeName}.xaml", UriKind.Absolute); var paletteUri = new Uri($"{ThemesDictionaryPath}Accents/{modeName}{paletteName}.xaml", UriKind.Absolute); if (modeChanged) ReplaceOrAddDictionary(themeUri, isTheme: true); if (paletteChanged) ReplaceOrAddDictionary(paletteUri, isTheme: false); if (animate && fromColors != null) { RefreshAnimatableKeysFromCurrentManagedDictionaries(appResources, merge: true); await AnimateToNewTheme(appResources, fromColors, durationMs).ConfigureAwait(true); } if (modeChanged) { ActiveThemeMode = newMode; ThemeModeChanged?.Invoke(newMode); } if (paletteChanged) { ActiveThemePalette = newPalette; ThemePaletteChanged?.Invoke(newPalette); } if (_persistenceEnabled && (modeChanged || paletteChanged)) { try { ThemePreferenceStore.Save(ActiveThemeMode, ActiveThemePalette); } catch { /* 持久化失败静默 */ } } } private static bool IsThemeDict(ResourceDictionary d) { var s = d.Source?.ToString(); return s != null && s.IndexOf("/Themes/", StringComparison.OrdinalIgnoreCase) >= 0 && s.IndexOf("/Accents/", StringComparison.OrdinalIgnoreCase) < 0 && (s.EndsWith("Light.xaml", StringComparison.OrdinalIgnoreCase) || s.EndsWith("Dark.xaml", StringComparison.OrdinalIgnoreCase)); } private static bool IsPaletteDict(ResourceDictionary d) { var s = d.Source?.ToString(); return s != null && s.IndexOf("/Themes/Accents/", StringComparison.OrdinalIgnoreCase) >= 0; } private static void ReplaceOrAddDictionary(Uri uri, bool isTheme) { var merged = Current.Resources.MergedDictionaries; if (isTheme) { for (int i = merged.Count - 1; i >= 0; i--) if (IsThemeDict(merged[i])) merged.RemoveAt(i); int firstPaletteIndex = -1; for (int i = 0; i < merged.Count; i++) if (IsPaletteDict(merged[i])) { firstPaletteIndex = i; break; } var newTheme = new ResourceDictionary { Source = uri }; if (firstPaletteIndex >= 0) merged.Insert(firstPaletteIndex, newTheme); else merged.Add(newTheme); } else { for (int i = merged.Count - 1; i >= 0; i--) if (IsPaletteDict(merged[i])) merged.RemoveAt(i); int themeIndex = -1; for (int i = 0; i < merged.Count; i++) if (IsThemeDict(merged[i])) { themeIndex = i; break; } var newPalette = new ResourceDictionary { Source = uri }; if (themeIndex >= 0 && themeIndex + 1 <= merged.Count) merged.Insert(themeIndex + 1, newPalette); else merged.Add(newPalette); } } #endregion #region Backward Compatible APIs /// /// 应用指定的主题调色板。 /// /// 要应用的主题调色板。 public static void ApplyThemePalette(ThemePalette themePalette) => ApplyTheme(null, themePalette, false); /// /// 【核心修改】根据给定的主题模式应用主题,不包括调色板的更改。 /// /// 要应用的主题模式(Light、Dark或System)。 public static void ApplyThemeMode(ThemeMode themeMode) => ApplyTheme(themeMode, null, false); /// /// 切换应用程序的主题模式。如果当前主题是亮色模式,则切换到暗色模式;如果当前主题是暗色模式,则切换到亮色模式。 /// public static void SwitchThemeMode() { var target = GetAppThemeMode() == ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light; ApplyTheme(target, ActiveThemePalette, false); } /// /// 【核心功能】以动画方式切换当前应用的主题模式(亮/暗)。 /// /// 动画持续时间,单位为毫秒,默认值为350毫秒。 /// 返回一个表示异步操作的任务。 public static Task SwitchThemeModeAnimatedAsync(int durationMs = 350) { var target = GetAppThemeMode() == ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light; return ApplyThemeAsync(target, ActiveThemePalette, true, durationMs); } /// /// 切换当前应用程序的主题模式,并使用动画效果。默认情况下,如果当前主题模式为浅色,则切换到深色模式;反之亦然。 /// /// 动画持续时间(以毫秒为单位),默认值为350毫秒。 public static void SwitchThemeModeAnimated(int durationMs = 350) { var target = GetAppThemeMode() == ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light; ApplyTheme(target, ActiveThemePalette, true, durationMs); } /// /// 【异步主题应用】根据给定的主题模式和调色板异步应用主题,并带有动画效果。 /// /// 要应用的主题模式,如浅色、深色或跟随系统。 /// 要应用的主题调色板,例如天蓝、墨绿等。 /// 动画持续时间(毫秒),默认为350毫秒。 /// 返回一个表示异步操作的任务。 public static Task ApplyThemeAnimatedAsync(ThemeMode mode, ThemePalette palette, int durationMs = 350) => ApplyThemeAsync(mode, palette, true, durationMs); #endregion #region Persistence /// /// 【启用持久化】允许主题设置在应用会话之间保持不变。 /// public static void EnablePersistence() => _persistenceEnabled = true; /// /// 【功能描述】禁用主题持久化功能。调用此方法后,将不会保存当前的主题设置到持久化存储中。 /// public static void DisablePersistence() => _persistenceEnabled = false; /// /// 【功能】从持久化存储中恢复上次设置的主题模式和调色板,并应用到应用程序。 /// 该方法尝试从持久化存储中加载主题模式和调色板,如果成功则应用这些设置。在应用过程中临时禁用主题设置的持久化以避免循环保存。 /// public static void RestorePersistedTheme() { // 此处应包含您自己的持久化加载逻辑 if (ThemePreferenceStore.TryLoad(out var mode, out var palette) && (mode.HasValue || palette.HasValue)) { var was = _persistenceEnabled; _persistenceEnabled = false; try { ApplyTheme(mode, palette, animate: false); } finally { _persistenceEnabled = was; } } } #endregion #region Debug #if DEBUG private static void DumpMerged(string tag) { System.Diagnostics.Debug.WriteLine($"==== {tag} MergedDictionaries ===="); int i = 0; foreach (var d in Current.Resources.MergedDictionaries) System.Diagnostics.Debug.WriteLine($"{i++}: {d.Source}"); } #endif #endregion #region UiApplication 封装 private static ThemeManager? _themeManagerInstance; private readonly Application? application; private Window? mainWindow; private ResourceDictionary? resources; private static bool ApplicationHasResources(Application application) { return application .Resources.MergedDictionaries .Where(e => e.Source is not null) .Any(e => e.Source.ToString().Contains(LibraryNamespace)); } /// /// 是否绑定到一个有效的 Application 实例。 /// public bool IsApplication => application is not null; private ThemeManager(Application? application) { if (application is null) { return; } if (!ApplicationHasResources(application)) { return; } this.application = application; } /// /// 【核心功能】关闭当前应用程序。 /// public void Shutdown() => application?.Shutdown(); /// /// 根据提供的资源键尝试从当前主题管理器的资源字典中查找资源。 /// /// 要查找的资源的键。 /// 如果找到,则返回与指定键关联的对象;否则返回null。 public object? TryFindResource(object resourceKey) => Resources[resourceKey]; /// /// 获取当前应用程序的ThemeManager实例。此属性确保在整个应用程序生命周期中只有一个ThemeManager实例。 /// /// /// 该属性通过检查是否存在已创建的ThemeManager实例来工作。如果尚未创建实例,它将使用当前的应用程序上下文创建一个新的实例。这样可以保证在任何需要访问ThemeManager的地方都能得到相同的实例,从而支持单例模式。 /// public static ThemeManager Current { get { _themeManagerInstance ??= new ThemeManager(Application.Current); return _themeManagerInstance; } } /// /// 获取或设置应用程序的主窗口。该属性允许访问和修改当前应用程序实例中的主窗口。 /// /// /// 通过此属性,可以获取当前应用程序的主窗口对象,或者设置一个新的主窗口对象。如果应用程序实例不为空,则直接使用其MainWindow属性;否则,使用内部存储的mainWindow变量。当设置新的主窗口时,如果应用程序实例存在,则同时更新应用程序实例的MainWindow属性。 /// public Window MainWindow { get => application?.MainWindow ?? mainWindow!; set { if (application != null) application.MainWindow = value; mainWindow = value; } } /// /// 获取或设置当前应用程序的资源字典。该属性用于管理和访问应用程序级别的资源,如样式、模板和控件资源。 /// /// /// 通过`Resources`属性,可以访问和修改应用程序的全局资源字典。这对于统一管理应用的主题、样式以及其他UI相关的资源非常有用。如果还没有为应用程序初始化资源字典,则在首次访问时会自动创建一个新的`ResourceDictionary`实例,并尝试加载默认主题和其他相关资源到这个字典中。此外,还允许直接替换整个资源字典,以实现动态改变应用程序外观的需求。 /// public ResourceDictionary Resources { get { if (resources == null) { resources = []; try { var themesDictionary = new ThemesDictionary() { Mode = ActiveThemeMode, Palette = ActiveThemePalette }; var controlsDictionary = new ControlsDictionary(); resources.MergedDictionaries.Add(themesDictionary); resources.MergedDictionaries.Add(controlsDictionary); } catch { /* 忽略初始化异常 */ } } return application?.Resources ?? resources; } set { if (application != null) application.Resources = value; resources = value; } } /// /// /// public static ThemeMode ActiveThemeMode { get; private set; } = GetAppThemeMode(); /// /// /// public static ThemePalette ActiveThemePalette { get; private set; } = GetAppThemePalette(); #endregion } }