From 2f445be837687544e1ca80658e6c3e65eb66bb09 Mon Sep 17 00:00:00 2001 From: uye <99072975+ABA2396@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:40:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=8E=AB=E5=A5=88=E5=8F=96=E8=89=B2=20?= =?UTF-8?q?(#17242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 移动关卡配置字段到 FightTask UseAlternateStage / HideUnavailableStage / CustomStageCode 已迁移至 FightTask.cs,从 GUI 全局配置中移除冗余定义 * feat: 莫奈取色 — 配置与本地化 新增 BackgroundMonetEnabled / BackgroundMonetMode / BackgroundMonetCustomColor 配置字段,以及 MonetModeType 枚举(Auto / Custom)。 添加 zh-cn / zh-tw / en-us / ja-jp / ko-kr 五语本地化字符串 * feat: 莫奈取色 — 调色板生成与应用逻辑 - MonetPaletteHelper: 基于 HSL 线性计算生成主题色板,主色 / 文字 / 背景角色各自有独立的饱和度与明度策略;明度随背景图不透明度连续 自适应,保证前景与有效背景之间始终有足够对比度 - ColorExtractorHelper: 从背景图 BitmapSource 提取主色 - ThemeHelper: ApplyMonetPalette / RevertMonetPalette / ReapplyMonet, 计算放后台线程、资源写入在 UI 线程;修正 AccentColor 覆盖顺序、 主题切换时清除遮蔽资源以恢复正确主题色 * feat: 莫奈取色 — 设置界面与 ViewModel - BackgroundSettings.xaml: 新增莫奈取色开关、取色模式选择、自定义颜色 色块预览与 ColorPicker 按钮 - BackgroundSettingsUserControlModel: UpdateMonet / ScheduleMonetUpdate 防抖逻辑,不透明度滑块拖动时后台计算 + UI 线程写入 - SettingsViewModel: 主题初始化完成后恢复莫奈调色板 * chore: review * chore: 调整间距 --- src/MaaWpfGui/Configuration/Global/GUI.cs | 29 +- src/MaaWpfGui/Helper/ColorExtractorHelper.cs | 192 +++++++++++++ src/MaaWpfGui/Helper/MonetPaletteHelper.cs | 266 ++++++++++++++++++ src/MaaWpfGui/Helper/ThemeHelper.cs | 186 ++++++++++++ src/MaaWpfGui/Res/Localizations/en-us.xaml | 6 + src/MaaWpfGui/Res/Localizations/ja-jp.xaml | 6 + src/MaaWpfGui/Res/Localizations/ko-kr.xaml | 6 + src/MaaWpfGui/Res/Localizations/zh-cn.xaml | 6 + src/MaaWpfGui/Res/Localizations/zh-tw.xaml | 6 + .../ViewModels/UI/SettingsViewModel.cs | 3 + .../BackgroundSettingsUserControlModel.cs | 255 ++++++++++++++++- .../Settings/BackgroundSettings.xaml | 72 ++++- 12 files changed, 1012 insertions(+), 21 deletions(-) create mode 100644 src/MaaWpfGui/Helper/ColorExtractorHelper.cs create mode 100644 src/MaaWpfGui/Helper/MonetPaletteHelper.cs diff --git a/src/MaaWpfGui/Configuration/Global/GUI.cs b/src/MaaWpfGui/Configuration/Global/GUI.cs index a759cec610..05b876ea1c 100644 --- a/src/MaaWpfGui/Configuration/Global/GUI.cs +++ b/src/MaaWpfGui/Configuration/Global/GUI.cs @@ -43,12 +43,6 @@ public class GUI : INotifyPropertyChanged public bool SaveWindowPlacement { get; set; } = true; - public bool UseAlternateStage { get; set; } = false; - - public bool HideUnavailableStage { get; set; } = true; - - public bool CustomStageCode { get; set; } = false; - public InverseClearType InverseClearMode { get; set; } = InverseClearType.Clear; public string WindowTitlePrefix { get; set; } = string.Empty; @@ -85,6 +79,13 @@ public class GUI : INotifyPropertyChanged public bool ExpanderAboutUs { get; set; } = true; + // ===== 背景设置(莫奈取色) ===== + public bool BackgroundMonetEnabled { get; set; } = false; + + public MonetModeType BackgroundMonetMode { get; set; } = MonetModeType.Auto; + + public string BackgroundMonetCustomColor { get; set; } = "#326CF3"; + [UsedImplicitly] public void OnPropertyChanged(string propertyName, object before, object after) { @@ -129,4 +130,20 @@ public class GUI : INotifyPropertyChanged /// ClearInverse, } + + /// + /// 莫奈取色的模式。 + /// + public enum MonetModeType + { + /// + /// 从背景图自动提取主色。 + /// + Auto = 0, + + /// + /// 用户手动选择颜色。 + /// + Custom, + } } diff --git a/src/MaaWpfGui/Helper/ColorExtractorHelper.cs b/src/MaaWpfGui/Helper/ColorExtractorHelper.cs new file mode 100644 index 0000000000..e36e0965b3 --- /dev/null +++ b/src/MaaWpfGui/Helper/ColorExtractorHelper.cs @@ -0,0 +1,192 @@ +// +// Part of the MaaWpfGui project, maintained by the MaaAssistantArknights team (Maa Team) +// Copyright (C) 2021-2025 MaaAssistantArknights Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License v3.0 only as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +namespace MaaWpfGui.Helper; + +/// +/// 从图片中提取主色(dominant color)的工具类。 +/// 采用降采样 + 颜色量化桶统计的方式,性能优于完整的 k-means。 +/// +public static class ColorExtractorHelper +{ + /// + /// 量化桶步长,将 RGB 空间按每 为一格分桶。 + /// + private const int BucketSize = 16; + + /// + /// 降采样后的最大边长(像素),用于加速取色。 + /// + private const int SampleSize = 32; + + /// + /// 从 BitmapImage 中提取出现频率最高的颜色。 + /// + /// 源图片。必须已调用过EndInit并完成解码。 + /// 提取到的主色;若图片无效则返回白色。 + public static Color ExtractDominantColor(BitmapSource image) + { + if (image == null) + { + return Colors.White; + } + + // 降采样到 ~32×32 以加速 + var scaledWidth = Math.Min(SampleSize, image.PixelWidth); + var scaledHeight = Math.Min(SampleSize, image.PixelHeight); + + var converted = new FormatConvertedBitmap(image, PixelFormats.Bgra32, null, 0); + var resized = new TransformedBitmap(converted, new ScaleTransform( + (double)scaledWidth / image.PixelWidth, + (double)scaledHeight / image.PixelHeight)); + + var pixels = new byte[scaledWidth * scaledHeight * 4]; + resized.CopyPixels(pixels, scaledWidth * 4, 0); + + // 量化到桶,统计频率 + var buckets = new Dictionary(); + + for (var i = 0; i < pixels.Length; i += 4) + { + var a = pixels[i + 3]; + if (a < 128) + { + // 跳过近透明像素 + continue; + } + + var r = pixels[i + 2]; // BGRA + var g = pixels[i + 1]; + var b = pixels[i]; + + var bucketR = r / BucketSize; + var bucketG = g / BucketSize; + var bucketB = b / BucketSize; + var key = (((bucketR * 256L) + bucketG) * 256L) + bucketB; + + if (buckets.TryGetValue(key, out var entry)) + { + buckets[key] = (entry.Count + 1, entry.R + r, entry.G + g, entry.B + b); + } + else + { + buckets[key] = (1, r, g, b); + } + } + + if (buckets.Count == 0) + { + return Colors.White; + } + + // 对每个桶计算「频率 × 鲜艳度」的综合得分,优先选高占比且鲜艳的颜色 + Color bestColor = Colors.White; + var bestScore = -1.0; + + foreach (var (_, value) in buckets) + { + long count = value.Count; + var avgR = (byte)(value.R / count); + var avgG = (byte)(value.G / count); + var avgB = (byte)(value.B / count); + var avgColor = Color.FromRgb(avgR, avgG, avgB); + + var (_, sat, _) = RgbToHsl(avgColor); + + // 频率占比 × 饱和度²,饱和度平方确保鲜艳颜色优先 + var frequencyRatio = (double)count / (scaledWidth * scaledHeight); + var score = frequencyRatio * sat * sat; + + if (score > bestScore) + { + bestScore = score; + bestColor = avgColor; + } + } + + return bestColor; + } + + /// + /// 异步版本的取色方法,将计算放到后台线程。 + /// + /// 源图片。 + /// 提取到的主色。 + public static Task ExtractDominantColorAsync(BitmapSource image) + { + if (image == null) + { + return Task.FromResult(Colors.White); + } + + // BitmapImage 必须 Freeze 后才能跨线程访问像素 + if (!image.IsFrozen) + { + image.Freeze(); + } + + return Task.Run(() => ExtractDominantColor(image)); + } + + /// + /// RGB → HSL 转换。 + /// + private static (double H, double S, double L) RgbToHsl(Color color) + { + var r = color.R / 255.0; + var g = color.G / 255.0; + var b = color.B / 255.0; + + var max = Math.Max(r, Math.Max(g, b)); + var min = Math.Min(r, Math.Min(g, b)); + var delta = max - min; + + var l = (max + min) / 2.0; + + if (delta == 0) + { + return (0, 0, l); + } + + var s = delta / (1 - Math.Abs((2 * l) - 1)); + + double h; + if (max == r) + { + h = ((g - b) / delta) % 6; + } + else if (max == g) + { + h = ((b - r) / delta) + 2; + } + else + { + h = ((r - g) / delta) + 4; + } + + h *= 60; + if (h < 0) + { + h += 360; + } + + return (h, s, l); + } +} diff --git a/src/MaaWpfGui/Helper/MonetPaletteHelper.cs b/src/MaaWpfGui/Helper/MonetPaletteHelper.cs new file mode 100644 index 0000000000..0f5bae09b1 --- /dev/null +++ b/src/MaaWpfGui/Helper/MonetPaletteHelper.cs @@ -0,0 +1,266 @@ +// +// Part of the MaaWpfGui project, maintained by the MaaAssistantArknights team (Maa Team) +// Copyright (C) 2021-2025 MaaAssistantArknights Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License v3.0 only as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY +// + +using System; +using System.Collections.Generic; +using System.Windows.Media; + +namespace MaaWpfGui.Helper; + +/// +/// 根据提取到的主色,通过 HSL 线性计算生成 Material You 风格的主题色板。 +/// 所有颜色均由 (色相, 饱和度, 明度) 直接计算,无离散档位量化,保证连续平滑。 +/// 文字与背景之间保持足够的明度距离以保证可读性。 +/// +public static class MonetPaletteHelper +{ + /// + /// 需要生成的主题资源 key 列表。 + /// + public static readonly string[] PaletteKeys = + [ + "PrimaryBrush", + "DarkPrimaryBrush", + "LightPrimaryBrush", + "TitleBrush", + "RegionBrush", + "RegionBrushOpacity10", + "RegionBrushOpacity25", + "RegionBrushOpacity50", + "RegionBrushOpacity75", + "MouseOverRegionBrush", + "MouseOverRegionBrushOpacity10", + "MouseOverRegionBrushOpacity25", + "MouseOverRegionBrushOpacity50", + "MouseOverRegionBrushOpacity75", + + // 亮度依赖的文字色:随明暗模式翻转,需跟随 region 明度适配 + "PrimaryTextBrush", + "ThirdlyTextBrush", + "TraceLogBrush", + "MessageLogBrush", + ]; + + // 原 Dark.xaml / Light.xaml 的 Alpha 值,保持不变 + private const byte Alpha10 = 0x19; // 10% + private const byte Alpha25 = 0x40; // 25% + private const byte Alpha50 = 0x7F; // 50% + private const byte Alpha75 = 0xBF; // 75% + + // ── 各角色的饱和度常量 ── + + /// + /// 主色(Primary)饱和度下限。原始主色饱和度不足此值时使用此值。 + /// + private const double PrimaryMinSaturation = 0.55; + + /// + /// 背景体系(Neutral Tint)饱和度。 + /// + private const double BackgroundSaturation = 0.16; + + /// + /// 文字色饱和度,极低饱和度接近中性色。 + /// + private const double TextSaturation = 0.06; + + // ── 对比度常量 ── + + /// + /// 文字与有效背景之间要求的最小明度差。 + /// 原版深色主题 PrimaryText(L=0.90) 与 Region(L=0.11) 差约 0.79。 + /// + private const double TextContrastDistance = 0.70; + + /// + /// 主色(PrimaryBrush)与有效背景之间要求的最小明度差。 + /// 原版 Primary(L=0.57) 与 Region(L=0.11) 差约 0.46。 + /// + private const double PrimaryContrastDistance = 0.45; + + // ── 固定明度常量(不随透明度变化的角色) ── + private const double RegionDarkL = 0.10; + private const double RegionLightL = 0.90; + private const double MouseOverDarkL = 0.20; + private const double MouseOverLightL = 0.70; + + /// + /// RegionBrushOpacity25 中底层(混合底)的占比(75%)。 + /// Opacity25 覆盖层为 25% RegionBrush + 75% 混合底, + /// 此常量即混合底在 effectiveRegionL 公式中的权重。 + /// + private const double Alpha25Factor = 0.75; + + /// + /// 背景图对有效背景明度的影响因子。 + /// 背景图虽然透过半透明层影响视觉,但文字的可读性主要取决于 RegionBrush 系列底色, + /// 因此只取背景图影响的 50%,避免文字色被背景图过度拉偏。 + /// + private const double BackgroundInfluence = 0.5; + + /// + /// 根据基础主色生成调色板。 + /// + /// 提取或用户选定的主色。 + /// 当前是否为深色模式。 + /// 背景图不透明度 (0~100),影响文字色的明度选取。 + /// 资源 key → 颜色的映射。 + public static Dictionary Generate(Color baseColor, bool isDark, int backgroundOpacity = 50) + { + var (hue, sat, _) = RgbToHsl(baseColor); + + // 主色饱和度:保留原色鲜艳度,但设下限避免过低饱和 + var primarySat = Math.Max(sat, PrimaryMinSaturation); + + // 有效背景明度:文字实际位于 RegionBrushOpacity25 覆盖层之上。 + // 背景图透过半透明层影响视觉,但对可读性的影响有限,只取部分影响。 + var regionL = isDark ? RegionDarkL : RegionLightL; + var baseL = RgbToHsl(baseColor).L; + var alpha = backgroundOpacity / 100.0; + var blendedL = (regionL * (1 - (alpha * BackgroundInfluence))) + (baseL * (alpha * BackgroundInfluence)); + + // Opacity25 覆盖层:25% region + 75% 混合底 + var effectiveRegionL = (blendedL * Alpha25Factor) + (regionL * (1 - Alpha25Factor)); + + // ── 固定明度角色:背景体系 ── + var region = HslToRgb(hue, BackgroundSaturation, isDark ? RegionDarkL : RegionLightL); + var mouseOver = HslToRgb(hue, BackgroundSaturation, isDark ? MouseOverDarkL : MouseOverLightL); + + // ── 自适应明度角色:主色系(随有效背景明度连续变化) ── + var primaryTargetL = isDark + ? Math.Min(effectiveRegionL + PrimaryContrastDistance, 0.90) + : Math.Max(effectiveRegionL - PrimaryContrastDistance, 0.10); + var primary = HslToRgb(hue, primarySat, primaryTargetL); + + var darkPrimary = isDark + ? HslToRgb(hue, primarySat, Math.Max(primaryTargetL - 0.10, 0.05)) + : primary; + var lightPrimary = HslToRgb(hue, primarySat, isDark ? 0.10 : 0.70); + + // ── 自适应明度角色:文字系 ── + var textTargetL = isDark + ? Math.Min(effectiveRegionL + TextContrastDistance, 0.90) + : Math.Max(effectiveRegionL - TextContrastDistance, 0.05); + var traceTargetL = isDark + ? Math.Min(effectiveRegionL + (TextContrastDistance * 0.7), 0.90) + : Math.Max(effectiveRegionL - (TextContrastDistance * 0.7), 0.05); + + var primaryText = HslToRgb(hue, TextSaturation, textTargetL); + var traceLog = HslToRgb(hue, TextSaturation, traceTargetL); + + var palette = new Dictionary + { + ["PrimaryBrush"] = primary, + ["DarkPrimaryBrush"] = darkPrimary, + ["LightPrimaryBrush"] = lightPrimary, + ["TitleBrush"] = primary, + ["RegionBrush"] = region, + ["MouseOverRegionBrush"] = mouseOver, + ["PrimaryTextBrush"] = primaryText, + ["ThirdlyTextBrush"] = Color.FromArgb(0x7F, primaryText.R, primaryText.G, primaryText.B), + ["TraceLogBrush"] = traceLog, + ["MessageLogBrush"] = primaryText, + }; + + // Opacity 系列 + palette["RegionBrushOpacity10"] = Color.FromArgb(Alpha10, region.R, region.G, region.B); + palette["RegionBrushOpacity25"] = Color.FromArgb(Alpha25, region.R, region.G, region.B); + palette["RegionBrushOpacity50"] = Color.FromArgb(Alpha50, region.R, region.G, region.B); + palette["RegionBrushOpacity75"] = Color.FromArgb(Alpha75, region.R, region.G, region.B); + + palette["MouseOverRegionBrushOpacity10"] = Color.FromArgb(Alpha10, mouseOver.R, mouseOver.G, mouseOver.B); + palette["MouseOverRegionBrushOpacity25"] = Color.FromArgb(Alpha25, mouseOver.R, mouseOver.G, mouseOver.B); + palette["MouseOverRegionBrushOpacity50"] = Color.FromArgb(Alpha50, mouseOver.R, mouseOver.G, mouseOver.B); + palette["MouseOverRegionBrushOpacity75"] = Color.FromArgb(Alpha75, mouseOver.R, mouseOver.G, mouseOver.B); + + return palette; + } + + /// + /// RGB → HSL 转换。 + /// + private static (double H, double S, double L) RgbToHsl(Color color) + { + var r = color.R / 255.0; + var g = color.G / 255.0; + var b = color.B / 255.0; + + var max = Math.Max(r, Math.Max(g, b)); + var min = Math.Min(r, Math.Min(g, b)); + var delta = max - min; + + var l = (max + min) / 2.0; + + if (delta == 0) + { + return (0, 0, l); + } + + var s = delta / (1 - Math.Abs((2 * l) - 1)); + + double h; + if (max == r) + { + h = ((g - b) / delta) % 6; + } + else if (max == g) + { + h = ((b - r) / delta) + 2; + } + else + { + h = ((r - g) / delta) + 4; + } + + h *= 60; + if (h < 0) + { + h += 360; + } + + return (h, s, l); + } + + /// + /// HSL → RGB 转换。 + /// + private static Color HslToRgb(double h, double s, double l) + { + if (s == 0) + { + var gray = (byte)(l * 255); + return Color.FromRgb(gray, gray, gray); + } + + var c = (1 - Math.Abs((2 * l) - 1)) * s; + var hp = h / 60.0; + var x = c * (1 - Math.Abs((hp % 2) - 1)); + + double r1, g1, b1; + switch ((int)hp) + { + case 0: r1 = c; g1 = x; b1 = 0; break; + case 1: r1 = x; g1 = c; b1 = 0; break; + case 2: r1 = 0; g1 = c; b1 = x; break; + case 3: r1 = 0; g1 = x; b1 = c; break; + case 4: r1 = x; g1 = 0; b1 = c; break; + default: r1 = c; g1 = 0; b1 = x; break; + } + + var m = l - (c / 2.0); + return Color.FromRgb( + (byte)((r1 + m) * 255), + (byte)((g1 + m) * 255), + (byte)((b1 + m) * 255)); + } +} diff --git a/src/MaaWpfGui/Helper/ThemeHelper.cs b/src/MaaWpfGui/Helper/ThemeHelper.cs index 4f124532d4..2c3ac8d36a 100644 --- a/src/MaaWpfGui/Helper/ThemeHelper.cs +++ b/src/MaaWpfGui/Helper/ThemeHelper.cs @@ -11,6 +11,9 @@ // but WITHOUT ANY WARRANTY // +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using System.Windows; using System.Windows.Media; using HandyControl.Themes; @@ -36,6 +39,9 @@ public static class ThemeHelper } Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor; + + // 系统主题切换后,莫奈调色板需要按新的明暗模式重新生成 + ReapplyMonet(); } public static void SwitchToLightMode() @@ -44,6 +50,7 @@ public static class ThemeHelper ThemeManager.Current.UsingWindowsAppTheme = false; ThemeManager.Current.ApplicationTheme = ApplicationTheme.Light; Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor; + ReapplyMonet(); } public static void SwitchToDarkMode() @@ -52,6 +59,7 @@ public static class ThemeHelper ThemeManager.Current.UsingWindowsAppTheme = false; ThemeManager.Current.ApplicationTheme = ApplicationTheme.Dark; Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor; + ReapplyMonet(); } public static void SwitchToSyncWithOsMode() @@ -67,10 +75,188 @@ public static class ThemeHelper } Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor; + ReapplyMonet(); } #endregion Swith Theme + #region Monet(莫奈取色) + + /// + /// 缓存的莫奈调色板的生成参数,用于明暗切换后重新生成。 + /// 为 null 表示莫奈取色未启用。 + /// + private static Color? _monetBaseColor; + + /// + /// 缓存的背景图不透明度,用于 ReapplyMonet 时重新生成文字色。 + /// + private static int _monetBackgroundOpacity = 50; + + /// + /// 保存被莫奈覆盖前的原始 brush,用于关闭莫奈时恢复。 + /// + private static readonly Dictionary _monetOriginalBrushes = []; + + /// + /// 莫奈取色是否已启用(即是否已调用过 ApplyMonetPalette 且未 Revert)。 + /// + public static bool IsMonetActive => _monetBaseColor != null; + + /// + /// 获取当前是否处于深色模式。 + /// + /// 深色模式返回 true。 + public static bool IsDarkMode() + { + return ThemeManager.Current.ApplicationTheme == ApplicationTheme.Dark; + } + + /// + /// 应用莫奈调色板,覆盖主题资源中的主色系和背景色系。 + /// 首次调用时会保存原始值以供回退。 + /// + /// 提取或用户选定的主色。 + /// 背景图不透明度 (0~100),影响文字色的明度选取。 + public static void ApplyMonetPalette(Color baseColor, int backgroundOpacity = 50) + { + var isDark = IsDarkMode(); + _monetBaseColor = baseColor; + _monetBackgroundOpacity = backgroundOpacity; + + // 调色板计算可在任意线程完成,无需占用 UI 线程 + var palette = MonetPaletteHelper.Generate(baseColor, isDark, backgroundOpacity); + + // 只有实际写入 WPF 资源才需要在 UI 线程执行 + Application.Current?.Dispatcher.Invoke(() => + { + ApplyPaletteToResources(palette, baseColor); + }); + } + + /// + /// 异步应用莫奈调色板:计算在后台线程,资源写入在 UI 线程。 + /// 适用于滑块拖动等高频调用场景,避免阻塞 UI。 + /// + /// 提取或用户选定的主色。 + /// 背景图不透明度 (0~100),影响文字色的明度选取。 + /// 取消令牌,用于防抖时取消过期的调色板计算。 + /// 表示异步操作的任务。 + public static async Task ApplyMonetPaletteAsync(Color baseColor, int backgroundOpacity = 50, CancellationToken cancellationToken = default) + { + var isDark = IsDarkMode(); + _monetBaseColor = baseColor; + _monetBackgroundOpacity = backgroundOpacity; + + // 在后台线程计算调色板 + var palette = await Task.Run(() => MonetPaletteHelper.Generate(baseColor, isDark, backgroundOpacity), cancellationToken) + .ConfigureAwait(true); + + // 在 UI 线程写入资源 + Application.Current?.Dispatcher.Invoke(() => + { + ApplyPaletteToResources(palette, baseColor); + }); + } + + /// + /// 将已计算好的调色板写入 WPF 资源(必须在 UI 线程调用)。 + /// + private static void ApplyPaletteToResources(Dictionary palette, Color baseColor) + { + // 首次应用时保存原始值 + SaveOriginalsIfNeeded(MonetPaletteHelper.PaletteKeys); + + // 先设置 AccentColor:HandyControl 的 ThemeManager 会根据它重新生成 + // PrimaryBrush / DarkPrimaryBrush / TitleBrush 等内部资源, + // 因此必须在 AccentColor 之后再覆盖为莫奈调色板的值,否则会被覆盖回去 + ThemeManager.Current.AccentColor = new SolidColorBrush(baseColor); + + foreach (var (key, color) in palette) + { + Application.Current.Resources[key] = new SolidColorBrush(color); + } + } + + /// + /// 回退莫奈调色板,恢复所有被覆盖的 brush 到原始值。 + /// + public static void RevertMonetPalette() + { + if (_monetBaseColor == null) + { + return; + } + + _monetBaseColor = null; + + // 只有资源写入需要在 UI 线程 + Application.Current?.Dispatcher.Invoke(() => + { + // 先恢复 AccentColor 到系统值(同样会重写 HandyControl 内部 brush) + if (!WineRuntimeInformation.IsRunningUnderWine) + { + ThemeManager.Current.AccentColor = ThemeManager.Current.GetAccentColorFromSystem(); + } + + // 在 AccentColor 之后再恢复原始 brush,避免被 HandyControl 覆盖 + foreach (var (key, brush) in _monetOriginalBrushes) + { + Application.Current.Resources[key] = brush; + } + + _monetOriginalBrushes.Clear(); + + Application.Current.Resources["TitleBrush"] = ThemeManager.Current.AccentColor; + }); + } + + /// + /// 当莫奈取色启用时,使用缓存的 baseColor 重新生成调色板。 + /// 用于明暗模式切换后同步更新。 + /// + public static void ReapplyMonet() + { + if (_monetBaseColor is Color color) + { + // 明暗模式切换后,HandyControl 会通过 ThemeResources 切换底层主题字典, + // 但莫奈之前直接写入 Application.Current.Resources 的值会遮蔽新主题的值。 + // 需要先移除这些直接覆盖项,让底层新主题值透出,再重新保存和生成。 + Application.Current?.Dispatcher.Invoke(() => + { + foreach (var key in MonetPaletteHelper.PaletteKeys) + { + Application.Current.Resources.Remove(key); + } + }); + + _monetOriginalBrushes.Clear(); + ApplyMonetPalette(color, _monetBackgroundOpacity); + } + } + + /// + /// 首次覆盖前保存原始 brush 值。 + /// + private static void SaveOriginalsIfNeeded(IEnumerable keys) + { + if (_monetOriginalBrushes.Count > 0) + { + return; + } + + foreach (var key in keys) + { + if (Application.Current?.Resources[key] is SolidColorBrush brush) + { + // 冻结副本,避免引用被修改 + _monetOriginalBrushes[key] = brush.Clone(); + } + } + } + + #endregion Monet(莫奈取色) + #region Check UiLogColor // In official version, using ResourceHelper.GetSkin diff --git a/src/MaaWpfGui/Res/Localizations/en-us.xaml b/src/MaaWpfGui/Res/Localizations/en-us.xaml index e98cd72e81..7a67d239fc 100644 --- a/src/MaaWpfGui/Res/Localizations/en-us.xaml +++ b/src/MaaWpfGui/Res/Localizations/en-us.xaml @@ -23,6 +23,12 @@ Fill Uniform (Fit) Uniform to Fill (Cover) + Monet Color + Color Mode + Auto Extract + Custom Color + Select Color + Extract dominant color from background image to generate theme palette UI Theme HotKeys Update diff --git a/src/MaaWpfGui/Res/Localizations/ja-jp.xaml b/src/MaaWpfGui/Res/Localizations/ja-jp.xaml index b62adca29c..01d64a3576 100644 --- a/src/MaaWpfGui/Res/Localizations/ja-jp.xaml +++ b/src/MaaWpfGui/Res/Localizations/ja-jp.xaml @@ -23,6 +23,12 @@ 引き伸ばし(フィル) アスペクト比保持(フィット) アスペクト比保持(トリミング) + モネ採色 + 採色モード + 自動採色 + カスタムカラー + 色を選択 + 背景画像からメインカラーを自動抽出してテーマ配色を生成します UI主題 ホットキー設定 アップデート設定 diff --git a/src/MaaWpfGui/Res/Localizations/ko-kr.xaml b/src/MaaWpfGui/Res/Localizations/ko-kr.xaml index 43b18022d6..d39ebbfb13 100644 --- a/src/MaaWpfGui/Res/Localizations/ko-kr.xaml +++ b/src/MaaWpfGui/Res/Localizations/ko-kr.xaml @@ -24,6 +24,12 @@ 늘려 채움 비율 유지 (맞춤) 비율 유지 (자르기) + 모네 색상 + 색상 모드 + 자동 추출 + 사용자 지정 색상 + 색상 선택 + 배경 이미지에서 주요 색상을 추출하여 테마 색상을 생성합니다 단축키 설정 업데이트 도전과제 diff --git a/src/MaaWpfGui/Res/Localizations/zh-cn.xaml b/src/MaaWpfGui/Res/Localizations/zh-cn.xaml index 6ae5e41b64..2123bc752f 100644 --- a/src/MaaWpfGui/Res/Localizations/zh-cn.xaml +++ b/src/MaaWpfGui/Res/Localizations/zh-cn.xaml @@ -23,6 +23,12 @@ 拉伸填充 等比适应 等比填充(裁剪) + 莫奈取色 + 取色模式 + 自动取色 + 自定义颜色 + 选择颜色 + 将从背景图自动提取主色生成主题配色 界面主题 热键设置 更新设置 diff --git a/src/MaaWpfGui/Res/Localizations/zh-tw.xaml b/src/MaaWpfGui/Res/Localizations/zh-tw.xaml index ae557fcbf7..9979378d29 100644 --- a/src/MaaWpfGui/Res/Localizations/zh-tw.xaml +++ b/src/MaaWpfGui/Res/Localizations/zh-tw.xaml @@ -23,6 +23,12 @@ 非等比例填滿 等比例縮放 等比例填滿(裁剪) + 莫奈取色 + 取色模式 + 自動取色 + 自訂顏色 + 選擇顏色 + 將從背景圖自動提取主色產生主題配色 介面主題 熱鍵設定 更新設定 diff --git a/src/MaaWpfGui/ViewModels/UI/SettingsViewModel.cs b/src/MaaWpfGui/ViewModels/UI/SettingsViewModel.cs index 4747af313e..c1f4e52e44 100644 --- a/src/MaaWpfGui/ViewModels/UI/SettingsViewModel.cs +++ b/src/MaaWpfGui/ViewModels/UI/SettingsViewModel.cs @@ -359,6 +359,9 @@ public class SettingsViewModel : Screen GuiSettings.LanguageList = languageList; GuiSettings.SwitchDarkMode(); + + // 主题初始化完成后,若莫奈取色已开启,恢复调色板(必须在主题切换之后执行) + BackgroundSettings.UpdateMonet(); } private void InitConnectConfig() diff --git a/src/MaaWpfGui/ViewModels/UserControl/Settings/BackgroundSettingsUserControlModel.cs b/src/MaaWpfGui/ViewModels/UserControl/Settings/BackgroundSettingsUserControlModel.cs index 880c81411e..69d4537a48 100644 --- a/src/MaaWpfGui/ViewModels/UserControl/Settings/BackgroundSettingsUserControlModel.cs +++ b/src/MaaWpfGui/ViewModels/UserControl/Settings/BackgroundSettingsUserControlModel.cs @@ -13,27 +13,53 @@ #nullable enable using System; -using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Threading; +using System.Threading.Tasks; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; +using HandyControl.Controls; +using HandyControl.Tools; +using MaaWpfGui.Configuration.Factory; using MaaWpfGui.Constants; using MaaWpfGui.Helper; +using MaaWpfGui.Utilities; using MaaWpfGui.Utilities.ValueType; using Microsoft.Win32; +using Serilog; using Stylet; +using MonetModeType = MaaWpfGui.Configuration.Global.GUI.MonetModeType; namespace MaaWpfGui.ViewModels.UserControl.Settings; public class BackgroundSettingsUserControlModel : PropertyChangedBase { + private static readonly ILogger _logger = Log.ForContext(); + + /// + /// 莫奈取色更新的防抖延迟(毫秒)。 + /// + private const int MonetDebounceMs = 150; + static BackgroundSettingsUserControlModel() { Instance = new(); } + private BackgroundSettingsUserControlModel() + { + PropertyDependsOnUtility.InitializePropertyDependencies(this); + LocalizationHelper.LanguageChanged += RefreshLocalization; + } + + public void RefreshLocalization() + { + BackgroundImageStretchModeList.RefreshLocalization(); + BackgroundMonetModeList.RefreshLocalization(); + } + public static BackgroundSettingsUserControlModel Instance { get; } private static string _backgroundImagePath = ConfigurationHelper.GetGlobalValue(ConfigurationKeys.BackgroundImagePath, "background/background.png"); @@ -46,6 +72,9 @@ public class BackgroundSettingsUserControlModel : PropertyChangedBase ConfigurationHelper.SetGlobalValue(ConfigurationKeys.BackgroundImagePath, value); BackgroundImage = RefreshBackgroundImage(value); + // 背景图变更后,若莫奈自动取色开启,重新提取主色 + UpdateMonet(); + AchievementTrackerHelper.Instance.Unlock(AchievementIds.CustomizationMaster); } } @@ -83,13 +112,11 @@ public class BackgroundSettingsUserControlModel : PropertyChangedBase } } - public static List BackgroundImageStretchModeList { get; } = - [ - new() { Display = LocalizationHelper.GetString("BackgroundImageStretchModeNone"), Value = Stretch.None.ToString() }, - new() { Display = LocalizationHelper.GetString("BackgroundImageStretchModeFill"), Value = Stretch.Fill.ToString() }, - new() { Display = LocalizationHelper.GetString("BackgroundImageStretchModeUniform"), Value = Stretch.Uniform.ToString() }, - new() { Display = LocalizationHelper.GetString("BackgroundImageStretchModeUniformToFill"), Value = Stretch.UniformToFill.ToString() }, - ]; + public LocalizedObservableList BackgroundImageStretchModeList { get; } = new( + (Stretch.None, "BackgroundImageStretchModeNone"), + (Stretch.Fill, "BackgroundImageStretchModeFill"), + (Stretch.Uniform, "BackgroundImageStretchModeUniform"), + (Stretch.UniformToFill, "BackgroundImageStretchModeUniformToFill")); private static BitmapImage? RefreshBackgroundImage(string imagePath) { @@ -124,6 +151,13 @@ public class BackgroundSettingsUserControlModel : PropertyChangedBase get => _backgroundOpacity; set { SetAndNotify(ref _backgroundOpacity, value); + + // 透明度变化后,若莫奈取色开启,重新生成调色板(文字色需根据新的有效背景明度选取) + // 滑块拖动会高频触发,使用防抖避免阻塞 UI + if (BackgroundMonetEnabled) + { + ScheduleMonetUpdate(); + } } } @@ -147,4 +181,209 @@ public class BackgroundSettingsUserControlModel : PropertyChangedBase ConfigurationHelper.SetGlobalValue(ConfigurationKeys.BackgroundOpacity, BackgroundOpacity.ToString(CultureInfo.InvariantCulture)); ConfigurationHelper.SetGlobalValue(ConfigurationKeys.BackgroundBlurEffectRadius, BackgroundBlurEffectRadius.ToString(CultureInfo.InvariantCulture)); } + + #region 莫奈取色(Monet Color) + + /// + /// 莫奈取色是否启用。 + /// + public bool BackgroundMonetEnabled + { + get => ConfigFactory.Root.GUI.BackgroundMonetEnabled; + set { + ConfigFactory.Root.GUI.BackgroundMonetEnabled = value; + NotifyOfPropertyChange(); + UpdateMonet(); + } + } + + /// + /// 莫奈取色模式(Auto / Custom)。 + /// + public MonetModeType BackgroundMonetMode + { + get => ConfigFactory.Root.GUI.BackgroundMonetMode; + set { + ConfigFactory.Root.GUI.BackgroundMonetMode = value; + NotifyOfPropertyChange(); + UpdateMonet(); + } + } + + [PropertyDependsOn(nameof(BackgroundMonetMode))] + public bool IsBackgroundMonetAuto => BackgroundMonetMode == MonetModeType.Auto; + + [PropertyDependsOn(nameof(BackgroundMonetMode))] + public bool IsBackgroundMonetCustom => BackgroundMonetMode == MonetModeType.Custom; + + /// + /// 莫奈取色模式下拉列表。 + /// + public LocalizedObservableList BackgroundMonetModeList { get; } = new( + (MonetModeType.Auto, "BackgroundMonetAuto"), + (MonetModeType.Custom, "BackgroundMonetCustom")); + + /// + /// 用户自定义颜色(HEX 字符串)。 + /// + public string BackgroundMonetCustomColor + { + get => ConfigFactory.Root.GUI.BackgroundMonetCustomColor; + set { + ConfigFactory.Root.GUI.BackgroundMonetCustomColor = value; + NotifyOfPropertyChange(); + NotifyOfPropertyChange(nameof(CurrentMonetColor)); + } + } + + /// + /// 当前生效的莫奈主色,用于色块预览。 + /// + public Color CurrentMonetColor + { + get { + if (!BackgroundMonetEnabled) + { + // 关闭时显示默认主色 + return ThemeHelper.String2Color("#326CF3"); + } + + return IsBackgroundMonetCustom + ? ThemeHelper.String2Color(BackgroundMonetCustomColor) + : _lastExtractedColor ?? ThemeHelper.String2Color("#326CF3"); + } + } + + /// + /// 上次自动提取到的颜色(缓存)。 + /// + private Color? _lastExtractedColor; + + /// + /// 打开 HandyControl ColorPicker 弹窗让用户选择自定义颜色。 + /// + public void SelectMonetColor() + { + var picker = SingleOpenHelper.CreateControl(); + + // 初始化 ColorPicker 为当前颜色 + var current = ThemeHelper.String2Color(BackgroundMonetCustomColor); + picker.SelectedBrush = new SolidColorBrush(current); + + var dialog = Dialog.Show(picker, nameof(Views.UI.RootView)); + + picker.Confirmed += (_, e) => { + var color = e.Info; + BackgroundMonetCustomColor = ThemeHelper.Color2HexString(color); + UpdateMonet(); + dialog.Close(); + }; + + picker.Canceled += (_, _) => dialog.Close(); + } + + /// + /// 防抖取消令牌源,避免滑块拖动时高频重新计算调色板。 + /// + private CancellationTokenSource? _monetUpdateCts; + + /// + /// 调度一次莫奈取色更新(防抖 150ms)。 + /// 滑块拖动等高频场景调用此方法,而非直接 。 + /// + private void ScheduleMonetUpdate() + { + _monetUpdateCts?.Cancel(); + _monetUpdateCts = new CancellationTokenSource(); + _ = UpdateMonetAsync(_monetUpdateCts.Token); + } + + /// + /// 根据当前配置执行莫奈取色逻辑。 + /// 自动 / 自定义模式都会将计算放到后台线程,仅资源写入在 UI 线程。 + /// + public void UpdateMonet() + { + _monetUpdateCts?.Cancel(); + _ = UpdateMonetAsync(CancellationToken.None); + } + + /// + /// 异步执行莫奈取色,支持取消(用于防抖)。 + /// 所有异常均在方法内部捕获并记录,避免 fire-and-forget 调用产生未观察异常。 + /// + /// 取消令牌。 + private async Task UpdateMonetAsync(CancellationToken cancellationToken) + { + // 防抖延迟:等待 150ms,期间若被取消则直接返回 + try + { + await Task.Delay(MonetDebounceMs, cancellationToken).ConfigureAwait(true); + } + catch (OperationCanceledException) + { + return; + } + + try + { + if (!BackgroundMonetEnabled) + { + ThemeHelper.RevertMonetPalette(); + NotifyOfPropertyChange(nameof(CurrentMonetColor)); + return; + } + + if (BackgroundMonetMode == MonetModeType.Custom) + { + // 自定义模式:直接用用户选定的颜色,计算放后台线程 + var color = ThemeHelper.String2Color(BackgroundMonetCustomColor); + await ThemeHelper.ApplyMonetPaletteAsync(color, BackgroundOpacity, cancellationToken); + NotifyOfPropertyChange(nameof(CurrentMonetColor)); + } + else + { + // 自动模式:从背景图提取主色(本身就是异步的) + await UpdateMonetAutoAsync(cancellationToken); + } + } + catch (OperationCanceledException) + { + // 正常的防抖取消,静默忽略 + } + catch (Exception ex) + { + // 兜底:fire-and-forget 调用中的异常不能逃逸,记录后忽略 + _logger.Error(ex, "Failed to update Monet palette."); + } + } + + /// + /// 异步执行自动取色:提取主色 → 应用调色板。 + /// 颜色提取和调色板计算在后台线程,资源写入在 UI 线程。 + /// + /// 取消令牌。 + private async Task UpdateMonetAutoAsync(CancellationToken cancellationToken) + { + var image = BackgroundImage; + if (image == null) + { + // 没有背景图时,取色无意义,先回退 + ThemeHelper.RevertMonetPalette(); + NotifyOfPropertyChange(nameof(CurrentMonetColor)); + return; + } + + var rawColor = await ColorExtractorHelper.ExtractDominantColorAsync(image).ConfigureAwait(true); + cancellationToken.ThrowIfCancellationRequested(); + + // 直接使用提取到的原始主色生成调色板 + _lastExtractedColor = rawColor; + + await ThemeHelper.ApplyMonetPaletteAsync(rawColor, BackgroundOpacity, cancellationToken); + + NotifyOfPropertyChange(nameof(CurrentMonetColor)); + } + + #endregion 莫奈取色(Monet Color) } diff --git a/src/MaaWpfGui/Views/UserControl/Settings/BackgroundSettings.xaml b/src/MaaWpfGui/Views/UserControl/Settings/BackgroundSettings.xaml index bf80aa68b8..330e041e37 100644 --- a/src/MaaWpfGui/Views/UserControl/Settings/BackgroundSettings.xaml +++ b/src/MaaWpfGui/Views/UserControl/Settings/BackgroundSettings.xaml @@ -12,12 +12,12 @@ xmlns:settings_vms="clr-namespace:MaaWpfGui.ViewModels.UserControl.Settings" d:Background="White" d:DataContext="{d:DesignInstance {x:Type settings_vms:BackgroundSettingsUserControlModel}}" - d:DesignHeight="300" + d:DesignHeight="350" d:DesignWidth="550" s:View.ActionTarget="{Binding}" mc:Ignorable="d"> - + + + + + + + + + + + + + + + + + + + +