using System.Reflection; using System.Windows.Media.Animation; using System.Collections; namespace NeoUI.Appearance; /// /// 主题管理核心类:负责 /// 1. 维护当前明暗模式 + 调色板状态(_activeThemeMode/_activeThemePalette) /// 2. 通过替换 Application.Resources.MergedDictionaries 中的主题/调色板 ResourceDictionary 实现运行时切换 /// 3. 可选颜色平滑动画(针对已有 SolidColorBrush 实例) /// 4. 确保同一时刻仅存在一个主题字典 + 一个调色板字典,避免覆盖顺序错误 /// /// 重要概念: /// - “主题字典” (Light.xaml / Dark.xaml) 定义基础背景 / 文本 / 语义色等 /// - “调色板字典” (LightBlue.xaml / DarkBlue.xaml 等) 定义品牌主色系(Primary*) /// - WPF 资源解析为“后添加优先覆盖前面”,因此顺序必须:主题 -> 调色板 -> 其它控件样式 /// - 控件 XAML 需使用 DynamicResource 才能在字典替换时刷新;StaticResource 仅解析一次 /// public class ThemeManager { /// 当前库程序集名称,用于组合 pack:// 路径 public static string LibraryNamespace => Assembly.GetExecutingAssembly().GetName().Name; /// 主题资源根路径(基础 + 调色板都基于此) public static string ThemesDictionaryPath => $"pack://application:,,,/{LibraryNamespace};component/Themes/"; /// 内部缓存:当前明暗模式(避免重复加载) private static ThemeMode _activeThemeMode = ThemeMode.Light; /// 内部缓存:当前调色板 private static ThemePalette _activeThemePalette = ThemePalette.Blue; // 持久化开关:通过 EnablePersistence 控制是否写入文件 private static bool _persistenceEnabled; /// 主题模式变更事件(成功应用后才触发) public static event Action? ThemeModeChanged; /// 主题调色板变更事件(成功应用后才触发) public static event Action? ThemePaletteChanged; #region Animation /// /// 对已有 SolidColorBrush 做颜色渐变动画。 /// 注意:它仅改变 Brush 实例的 Color,不负责替换资源字典;动画完成后再真正替换字典可保证: /// - 已绑定此 Brush 的控件看到平滑过渡 /// - 替换字典后新增键/非 Brush 资源被更新 /// 限制:冻结 (Frozen) 的 Brush 无法动画(会直接跳过) /// private static Task AnimateBrushColorAsync(SolidColorBrush brush, Color toColor, int durationMs = 500) { if (brush.IsFrozen) return Task.CompletedTask; // 设计或某些场景可能出现冻结,如手动 Freeze() var tcs = new TaskCompletionSource(); var anim = new ColorAnimation { To = toColor, Duration = TimeSpan.FromMilliseconds(durationMs), EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut }, FillBehavior = FillBehavior.HoldEnd // 保留最终值(防止回弹) }; // Completed 总是回到 UI 线程上下文 anim.Completed += (_, _) => tcs.TrySetResult(true); brush.BeginAnimation(SolidColorBrush.ColorProperty, anim); return tcs.Task; } #endregion #region Public Query /// /// 解析 Application 资源中当前主题字典(文件名包含 light.xaml / dark.xaml)以同步内部缓存。 /// 说明:外部调用可能绕过本类直接替换资源,因此这里以资源实际状态为准。 /// public static ThemeMode GetAppThemeMode() { var dict = new ResourceDictionaryManager().LookupDictionary("themes"); if (dict?.Source != null) { var uri = dict.Source.ToString(); if (uri.IndexOf("light.xaml", StringComparison.OrdinalIgnoreCase) >= 0) _activeThemeMode = ThemeMode.Light; else if (uri.IndexOf("dark.xaml", StringComparison.OrdinalIgnoreCase) >= 0) _activeThemeMode = ThemeMode.Dark; } return _activeThemeMode; } /// /// 同上:根据当前调色板字典文件名更新内部缓存。 /// 注意:调色板文件名包含 Light/Dark 前缀 + 颜色名,例如 LightBlue.xaml / DarkBlue.xaml /// public static ThemePalette GetAppThemePalette() { var dict = new ResourceDictionaryManager().LookupDictionary("ColorPalette"); if (dict?.Source != null) { var uri = dict.Source.ToString(); if (uri.IndexOf("Blue.xaml", StringComparison.OrdinalIgnoreCase) >= 0) _activeThemePalette = ThemePalette.Blue; else if (uri.IndexOf("Green.xaml", StringComparison.OrdinalIgnoreCase) >= 0) _activeThemePalette = ThemePalette.Green; else if (uri.IndexOf("Purple.xaml", StringComparison.OrdinalIgnoreCase) >= 0) _activeThemePalette = ThemePalette.Purple; } return _activeThemePalette; } #endregion /// /// 将当前 Application 级主题资源合并到某个单独窗口/控件的 Resources(用于隔离场景提前绑定)。 /// 通常不需要调用;只有在局部字典需要显式继承应用级主题时使用。 /// 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); } } #region Unified Theme API /// /// 异步统一入口:可同时改变明暗模式 + 调色板。只传 mode 或 palette 代表单独改变。 /// animate=true 启用颜色渐变(注意:仅对已经存在的 SolidColorBrush 有效果)。 /// public static Task ApplyThemeAsync(ThemeMode? mode = null, ThemePalette? palette = null, bool animate = false, int animationDurationMs = 350) => InternalApplyThemeAsync(mode, palette, animate, animationDurationMs); /// /// 同步封装(内部实际仍走异步,只是阻塞等待)。 /// UI 线程中调用时不建议长动画阻塞;如果需要平滑体验请直接使用 await ApplyThemeAsync。 /// public static void ApplyTheme(ThemeMode? mode = null, ThemePalette? palette = null, bool animate = false, int animationDurationMs = 350) => InternalApplyThemeAsync(mode, palette, animate, animationDurationMs).GetAwaiter().GetResult(); /// /// 主题/调色板应用核心逻辑: /// 1. 计算是否真的发生变化(避免重复加载) /// 2. 预加载新字典(不合并)用于取目标颜色参与动画 /// 3. 可选对现有 Brush 做动画(不创建新实例,避免引用断开) /// 4. 替换 ResourceDictionary(删除旧 -> 添加新,保证唯一顺序) /// 5. 更新内部状态并触发事件 /// private static async Task InternalApplyThemeAsync(ThemeMode? mode, ThemePalette? palette, bool animate, int durationMs) { // Step1: 获取真实当前状态(可能被外部改动过) var currentMode = GetAppThemeMode(); var currentPalette = GetAppThemePalette(); // Step2: 计算目标(未传则沿用现值) var newMode = mode ?? currentMode; var newPalette = palette ?? currentPalette; // Step3: 判断变化 // 明暗模式改变时:对应调色板文件(LightBlue -> DarkBlue)必然需要重新加载,即便颜色名相同 bool modeChanged = newMode != currentMode; bool paletteChanged = newPalette != currentPalette || modeChanged; if (!modeChanged && !paletteChanged) return; // 无变化直接结束 // Step4: 组装文件名(当前命名约定:Dark/Light + 调色板名) string modeName = newMode == ThemeMode.Dark ? "Dark" : "Light"; string paletteName = newPalette switch { ThemePalette.Blue => "Blue", ThemePalette.Green => "Green", ThemePalette.Purple => "Purple", _ => "Blue" // 兜底,理论不应走到 }; // Step5: 构造 Uri(pack://) var themeUri = new Uri($"{ThemesDictionaryPath}{modeName}.xaml", UriKind.Absolute); var paletteUri = new Uri($"{ThemesDictionaryPath}ColorPalette/{modeName}{paletteName}.xaml", UriKind.Absolute); // Step6: 预加载字典(此时资源已经解析,可取得目标 Brush 颜色) ResourceDictionary? newThemeDict = null; ResourceDictionary? newPaletteDict = null; if (modeChanged) newThemeDict = new ResourceDictionary { Source = themeUri }; if (paletteChanged) newPaletteDict = new ResourceDictionary { Source = paletteUri }; // Step7: 动画(仅针对已有 Brush) if (animate) { var tasks = new List(); if (newThemeDict != null) CollectBrushAnimations(newThemeDict, tasks, durationMs); if (newPaletteDict != null) CollectBrushAnimations(newPaletteDict, tasks, durationMs); if (tasks.Count > 0) await Task.WhenAll(tasks); // 动画完成后,将已动画的旧 Brush 实例复用到新字典,避免字典替换产生新实例导致“跳变” if (newThemeDict != null) ReuseAnimatedBrushInstances(newThemeDict); if (newPaletteDict != null) ReuseAnimatedBrushInstances(newPaletteDict); } // Step8: 真正替换 / 添加字典(保证唯一 + 顺序) if (modeChanged) ReplaceOrAddDictionary(themeUri, isTheme: true); if (paletteChanged) ReplaceOrAddDictionary(paletteUri, isTheme: false); // Step9: 更新内部状态 & 发事件(只在实际变更时触发) if (modeChanged) { _activeThemeMode = newMode; ThemeModeChanged?.Invoke(newMode); } if (paletteChanged) { _activeThemePalette = newPalette; ThemePaletteChanged?.Invoke(newPalette); } // Step10: 可选持久化(只要实际有改变才写) if (_persistenceEnabled && (modeChanged || paletteChanged)) { try { ThemePreferenceStore.Save(_activeThemeMode, _activeThemePalette); } catch { /* 忽略 */ } } #if DEBUG // 调试辅助:输出当前合并字典列表,便于确认顺序是否正确,以及是否重复遗留 DumpMerged("AfterApplyTheme"); #endif } /// /// 为动画阶段收集“可以做颜色过渡”的画刷任务:仅当新字典中存在相同 Key 且旧资源中对应是 SolidColorBrush。 /// 注意:这里不替换 Brush 实例,只修改 Color,保持绑定引用稳定。 /// private static void CollectBrushAnimations(ResourceDictionary? newDict, List tasks, int durationMs) { var existing = Current.Resources; foreach (DictionaryEntry entry in newDict) { if (entry.Value is SolidColorBrush newBrush && existing[entry.Key] is SolidColorBrush oldBrush && !ReferenceEquals(oldBrush, newBrush)) tasks.Add(AnimateBrushColorAsync(oldBrush, newBrush.Color, durationMs)); } } /// /// 动画后复用已经过渡完成的旧 Brush 实例,避免替换字典时创建新实例导致闪烁或动画丢失。 /// 仅覆盖同 Key SolidColorBrush;其它资源仍使用新字典的对象(确保新增键/不同类型可生效)。 /// private static void ReuseAnimatedBrushInstances(ResourceDictionary? newDict) { var existing = Current.Resources; var keys = new List(); if (newDict == null) return; foreach (DictionaryEntry entry in newDict) { if (entry.Value is SolidColorBrush && existing[entry.Key] is SolidColorBrush existingBrush) { keys.Add(entry.Key); } } foreach (var key in keys) { newDict[key] = existing[key]; // 复用经过动画的旧实例 } } /// /// 核心替换策略: /// 1. isTheme=true → 删除所有旧的 Light.xaml/Dark.xaml 后再插入新主题(插在所有调色板之前) /// 2. isTheme=false → 删除所有旧调色板后,在主题后面插入新调色板 /// 保证:不会出现多个主题或多个调色板同时存在;顺序固定;避免旧的 Light 覆盖新 Dark。 /// private static void ReplaceOrAddDictionary(Uri uri, bool isTheme) { var merged = Current.Resources.MergedDictionaries; // 判定函数:主题字典(不包含 /ColorPalette/ 且文件名为 Light.xaml 或 Dark.xaml) bool IsThemeDict(ResourceDictionary d) { var s = d.Source?.ToString(); return s != null && s.IndexOf("/Themes/", StringComparison.OrdinalIgnoreCase) >= 0 && s.IndexOf("/ColorPalette/", StringComparison.OrdinalIgnoreCase) < 0 && (s.EndsWith("Light.xaml", StringComparison.OrdinalIgnoreCase) || s.EndsWith("Dark.xaml", StringComparison.OrdinalIgnoreCase)); } // 判定函数:调色板字典(路径包含 /Themes/ColorPalette/) bool IsPaletteDict(ResourceDictionary d) { var s = d.Source?.ToString(); return s != null && s.IndexOf("/Themes/ColorPalette/", StringComparison.OrdinalIgnoreCase) >= 0; } if (isTheme) { // A. 先删除所有旧主题字典(倒序防止索引错位) for (int i = merged.Count - 1; i >= 0; i--) if (IsThemeDict(merged[i])) merged.RemoveAt(i); // B. 找到第一个调色板位置(主题应在其前面) int firstPaletteIndex = -1; for (int i = 0; i < merged.Count; i++) if (IsPaletteDict(merged[i])) { firstPaletteIndex = i; break; } // C. 插入新主题(在调色板前,保证调色板覆盖可能重复键) var newTheme = new ResourceDictionary { Source = uri }; if (firstPaletteIndex >= 0) merged.Insert(firstPaletteIndex, newTheme); else merged.Add(newTheme); } else { // 调色板替换流程: // 1. 删除旧调色板 for (int i = merged.Count - 1; i >= 0; i--) if (IsPaletteDict(merged[i])) merged.RemoveAt(i); // 2. 定位主题字典(插在它后面) int themeIndex = -1; for (int i = 0; i < merged.Count; i++) if (IsThemeDict(merged[i])) { themeIndex = i; break; } // 3. 插入新调色板 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); /// /// 保留旧命名接口:仅换明暗模式。 /// public static void ApplyThemeMode(ThemeMode themeMode) => ApplyTheme(themeMode, null, false); /// /// 快捷切换 Light Dark(保持当前调色板颜色名称,但会加载对应模式版本)。 /// public static void SwitchThemeMode() { var target = GetAppThemeMode() == ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light; ApplyTheme(target, _activeThemePalette, false); } // === PATCH START: 新增带动画便捷方法 === /// /// 仅切换明暗模式(带动画)。 /// public static Task SwitchThemeModeAnimatedAsync(int durationMs = 350) { var target = GetAppThemeMode() == ThemeMode.Light ? ThemeMode.Dark : ThemeMode.Light; return ApplyThemeAsync(target, _activeThemePalette, true, durationMs); } /// /// 同步封装:仅切换明暗模式(带动画)。 /// public static void SwitchThemeModeAnimated(int durationMs = 350) { SwitchThemeModeAnimatedAsync(durationMs).GetAwaiter().GetResult(); } /// /// 指定模式 + 调色板动画切换(语义糖)。 /// public static Task ApplyThemeAnimatedAsync(ThemeMode mode, ThemePalette palette, int durationMs = 350) => ApplyThemeAsync(mode, palette, true, durationMs); // === PATCH END === /// /// 启用主题持久化(后续每次成功切换会写入本地文件)。 /// 需在 RestorePersistedTheme 之后调用(避免第一次加载又覆盖未应用的老值)。 /// public static void EnablePersistence() => _persistenceEnabled = true; /// /// 禁用主题持久化(停止写入文件)。 /// public static void DisablePersistence() => _persistenceEnabled = false; /// /// 启动时调用:读取上次用户选择并应用(不启用动画,不触发保存)。 /// 在 App.xaml.cs OnStartup 或 MainWindow 构造最早调用。 /// public static void RestorePersistedTheme() { ThemeMode? mode; ThemePalette? palette; if (ThemePreferenceStore.TryLoad(out mode, out palette) && (mode.HasValue || palette.HasValue)) { var wasEnabled = _persistenceEnabled; _persistenceEnabled = false; // 防止恢复时立刻写文件 try { ApplyTheme(mode, palette, animate: false); } finally { _persistenceEnabled = wasEnabled; } } } #endregion #region Debug #if DEBUG /// /// 调试辅助:输出合并字典当前顺序及其 Source,用于确认是否存在重复主题或顺序错误。 /// 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; /// /// 私有构造:封装对 Application 的引用;不在此进行主题逻辑(避免启动时副作用)。 /// private ThemeManager(Application? application) { if (application == null) return; this.application = application; System.Diagnostics.Debug.WriteLine($"INFO | {typeof(ThemeManager)} application is {this.application}", ThemeManager.LibraryNamespace); } /// 关闭应用(与主题无直接关系,仅做统一封装) public void Shutdown() => application?.Shutdown(); /// 尝试获取资源(简单封装) public object TryFindResource(object resourceKey) => Resources[resourceKey]; /// 单例访问(懒加载) public static ThemeManager? Current { get { if (_themeManagerInstance == null) _themeManagerInstance = new ThemeManager(System.Windows.Application.Current); return _themeManagerInstance; } } /// 主窗口引用(不影响主题;可用于窗口内调用 ApplyResourcesToElement) public Window MainWindow { get => application?.MainWindow ?? mainWindow; set { if (application != null) application.MainWindow = value; mainWindow = value; } } /// /// 返回应用级 ResourceDictionary: /// - 初次访问时构建一个包含 ThemesDictionary(默认 Light) + ControlsDictionary 的集合 /// - ThemesDictionary 会在首次真正切换时被 ReplaceOrAddDictionary 移除,替换为精确主题字典 /// - 这样设计允许:设计器/启动阶段有默认资源,后续切换保证唯一 /// public ResourceDictionary Resources { get { if (resources != null) return application?.Resources ?? resources; resources = new ResourceDictionary(); try { // 注意:ThemesDictionary 内部会加载 Light.xaml(设计期友好)。 // 后续切换会删除它并插入新的主题字典。 var themesDictionary = new ThemesDictionary(); 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; } } #endregion }