ThemeOverlay.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Windows;
  4. using System.Windows.Media;
  5. using TeamAAS;
  6. namespace TeamAAS.Theme
  7. {
  8. /// <summary>
  9. /// 主题画刷覆盖层:主色驱动的专业配色系统核心。
  10. ///
  11. /// 原理(对齐 HandyControls fork 的 ThemeResources 机制):
  12. /// 把一张【纯值覆盖字典】Add 到应用合并字典【末尾】——WPF 合并字典查找为
  13. /// "后添加的先查"(逆序),覆盖层获得最高查找优先级,元素/样式重查任何
  14. /// 画刷/调色板键都命中这里的纯值,官方大字典被遮蔽。
  15. ///
  16. /// 构成:
  17. /// 1. 官方基准(全量):启动/整替官方字典后,把调色板 Color 键与画刷以
  18. /// "求值冻结"形式捕获为纯值基准(此时求值链干净,值得官方色彩真传);
  19. /// 中性色/语义色沿用官方值,保证与 HC 控件生态协调。
  20. /// 2. 主色派生(专业配色):PrimaryBrush/DarkPrimaryBrush/TitleBrush 及对应 Color 键
  21. /// 由用户主色按 HSL 派生(暗色下主色提亮保对比、hover 变体 darken 12%),
  22. /// ReverseTextColor 按主色亮度自动选黑/白(WCAG AA 可读性)。
  23. ///
  24. /// 收益:
  25. /// - 覆盖画刷是纯值(无 DynamicResource 表达式)→ 天然免疫第三方资源污染;
  26. /// - 明暗/主色切换 = 重建轻量覆盖字典(毫秒级)→ 不触碰官方大字典 → 无全窗口样式重算(丝滑);
  27. /// - 明暗基准按 dark/light 各缓存一份,首次遇到某明暗需整替官方字典捕获,之后纯覆盖切换。
  28. /// </summary>
  29. internal static class ThemeOverlay
  30. {
  31. /// <summary>HC 主色系画刷键:值由用户主色派生(而非官方基准)。</summary>
  32. private static readonly string[] AccentDrivenBrushes =
  33. { "PrimaryBrush", "DarkPrimaryBrush", "TitleBrush" };
  34. /// <summary>HC 主色系调色板键:与画刷同步派生。</summary>
  35. private static readonly string[] AccentDrivenColors =
  36. { "PrimaryColor", "DarkPrimaryColor" };
  37. /// <summary>当前覆盖字典(Purge 放行判据;Add 于应用合并字典末尾)。</summary>
  38. private static ResourceDictionary _overlay;
  39. /// <summary>官方基准缓存:明暗 → (键 → 官方纯值)。官方值来自整替后的干净字典。</summary>
  40. private static readonly Dictionary<bool, Dictionary<string, object>> _baseCache =
  41. new Dictionary<bool, Dictionary<string, object>>();
  42. /// <summary>当前明暗(整替兜底路径的默认值来源)。</summary>
  43. public static bool CurrentDark { get; private set; }
  44. /// <summary>当前主色(整替兜底路径重建覆盖时的来源)。</summary>
  45. public static Color CurrentAccent { get; private set; } = Color.FromRgb(0x2D, 0x6C, 0xDF);
  46. /// <summary>官方基准是否已捕获(某明暗)。</summary>
  47. public static bool HasBase(bool dark)
  48. {
  49. return _baseCache.ContainsKey(dark);
  50. }
  51. /// <summary>
  52. /// 捕获官方基准:在官方字典刚被整替(干净、未被污染)时调用。
  53. /// 递归收集白名单官方字典中的 Brush(求值冻结)与 Color 键。
  54. /// </summary>
  55. public static void CaptureBase(bool dark)
  56. {
  57. var app = Application.Current;
  58. if (app == null) return;
  59. try
  60. {
  61. var baseDict = new Dictionary<string, object>(StringComparer.Ordinal);
  62. CollectOfficial(app.Resources, baseDict);
  63. _baseCache[dark] = baseDict;
  64. AppLogger.Info(
  65. $"[主题覆盖] 已捕获 {(dark ? "暗色" : "浅色")}官方基准({baseDict.Count} 项)",
  66. nameof(ThemeOverlay));
  67. }
  68. catch (Exception ex)
  69. {
  70. AppLogger.Error("[主题覆盖] 官方基准捕获失败", ex, nameof(ThemeOverlay));
  71. }
  72. }
  73. /// <summary>递归收集官方字典中的 Brush(冻结纯值)与 Color 键。</summary>
  74. private static void CollectOfficial(ResourceDictionary dict, Dictionary<string, object> into)
  75. {
  76. // 只认官方字典(第三方注入字典由 Purge 负责移除,不进基准)
  77. var merged = dict.MergedDictionaries;
  78. for (int i = 0; i < merged.Count; i++)
  79. {
  80. var child = merged[i];
  81. if (IsOfficialDict(child)) CollectOfficial(child, into);
  82. }
  83. foreach (var key in dict.Keys)
  84. {
  85. var keyName = key as string;
  86. if (keyName == null) continue;
  87. object value;
  88. try { value = dict[key]; }
  89. catch { continue; }
  90. var brush = value as Brush;
  91. if (brush != null)
  92. {
  93. try { into[keyName] = brush.GetCurrentValueAsFrozen(); } // 求值冻结:官方当前色纯值
  94. catch { /* 求值失败(如被污染)跳过,该键保持未覆盖 */ }
  95. continue;
  96. }
  97. if (value is Color) into[keyName] = value; // 调色板 Color 键
  98. }
  99. }
  100. /// <summary>
  101. /// 构建并应用覆盖字典(明暗/主色变化的统一入口,毫秒级,丝滑)。
  102. /// 若该明暗的官方基准尚未捕获(首次遇到),返回 false 由调用方先整替官方字典。
  103. /// </summary>
  104. public static bool Apply(bool dark, Color accent)
  105. {
  106. Dictionary<string, object> baseDict;
  107. if (!_baseCache.TryGetValue(dark, out baseDict)) return false;
  108. var app = Application.Current;
  109. if (app == null) return true;
  110. try
  111. {
  112. // 记录当前主题状态(整替兜底路径的恢复来源)
  113. CurrentDark = dark;
  114. CurrentAccent = accent;
  115. // 主色在暗色下轻微提亮,保证在深背景上的对比度(专业配色的明暗适配)
  116. var effectiveAccent = dark ? ColorUtility.Lighten(accent, 0.08) : accent;
  117. var hoverAccent = ColorUtility.Darken(effectiveAccent, 0.12);
  118. var textOnAccent = ColorUtility.ContrastText(effectiveAccent);
  119. var overlay = new ResourceDictionary();
  120. // 1) 官方基准全量(纯值:中性色/语义色沿用官方,天然协调)
  121. foreach (var kv in baseDict)
  122. {
  123. overlay[kv.Key] = kv.Value;
  124. }
  125. // 2) 主色系派生(覆盖基准中的官方主色)
  126. foreach (var key in AccentDrivenBrushes)
  127. {
  128. if (!baseDict.ContainsKey(key)) continue;
  129. Color c;
  130. if (key == "DarkPrimaryBrush") c = hoverAccent;
  131. else c = effectiveAccent;
  132. var brush = new SolidColorBrush(c);
  133. brush.Freeze();
  134. overlay[key] = brush;
  135. }
  136. foreach (var key in AccentDrivenColors)
  137. {
  138. if (!baseDict.ContainsKey(key)) continue;
  139. overlay[key] = key == "DarkPrimaryColor" ? hoverAccent : effectiveAccent;
  140. }
  141. // 3) 主色上的文字对比色:写入 ReverseTextColor(HC 官方语义即"反色文字",用于
  142. // 主色背景上的按钮文字等),按主色亮度自动选黑/白(避免浅色主色+白字看不清)。
  143. // 【注意】绝不能动 PrimaryTextBrush——那是全局正文主文本色,明暗适配由官方
  144. // 调色板负责;若按主色派生会让浅色主题正文全变白字(白底白字,实测 2026-09-04)
  145. if (baseDict.ContainsKey("ReverseTextColor"))
  146. {
  147. var brush = new SolidColorBrush(textOnAccent);
  148. brush.Freeze();
  149. overlay["ReverseTextColor"] = brush;
  150. }
  151. // 4) 替换/插入覆盖层。
  152. // 【关键】WPF 合并字典的查找顺序是"后添加的先查"(逆序):
  153. // 覆盖层必须 Add 到合并字典【末尾】才能获得最高查找优先级;
  154. // 若 Insert(0) 放最前面,反而会被 Theme.xaml 的旧画刷遮蔽(实测:切换后颜色不动)
  155. var merged = app.Resources.MergedDictionaries;
  156. if (_overlay != null) merged.Remove(_overlay);
  157. _overlay = overlay;
  158. merged.Add(_overlay);
  159. }
  160. catch (Exception ex)
  161. {
  162. AppLogger.Error("[主题覆盖] 覆盖字典构建失败", ex, nameof(ThemeOverlay));
  163. }
  164. return true;
  165. }
  166. /// <summary>Purge 放行判据:是否我们的覆盖字典。</summary>
  167. public static bool IsOverlay(ResourceDictionary dict)
  168. {
  169. return dict != null && ReferenceEquals(dict, _overlay);
  170. }
  171. /// <summary>
  172. /// 摘除覆盖层(不重建)。整替官方字典前必须调用——
  173. /// 否则基准捕获会被上一轮覆盖值污染(覆盖层查找优先级最高)。
  174. /// </summary>
  175. public static void RemoveOverlay()
  176. {
  177. if (_overlay == null) return;
  178. try { Application.Current?.Resources.MergedDictionaries.Remove(_overlay); }
  179. catch { /* 移除失败忽略 */ }
  180. _overlay = null;
  181. }
  182. /// <summary>官方字典判定(与 VmResourceGuard 同规则:Source 含 HandyControl)。</summary>
  183. private static bool IsOfficialDict(ResourceDictionary dict)
  184. {
  185. try
  186. {
  187. var src = dict.Source;
  188. return src != null && src.OriginalString.Contains("HandyControl");
  189. }
  190. catch
  191. {
  192. return false;
  193. }
  194. }
  195. }
  196. }