feat: 莫奈取色 (#17242)

* 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: 调整间距
This commit is contained in:
uye
2026-07-02 22:40:32 +08:00
committed by GitHub
parent b65b3d3d16
commit 2f445be837
12 changed files with 1012 additions and 21 deletions

View File

@@ -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
/// </summary>
ClearInverse,
}
/// <summary>
/// 莫奈取色的模式。
/// </summary>
public enum MonetModeType
{
/// <summary>
/// 从背景图自动提取主色。
/// </summary>
Auto = 0,
/// <summary>
/// 用户手动选择颜色。
/// </summary>
Custom,
}
}

View File

@@ -0,0 +1,192 @@
// <copyright file="ColorExtractorHelper.cs" company="MaaAssistantArknights">
// 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
// </copyright>
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;
/// <summary>
/// 从图片中提取主色dominant color的工具类。
/// 采用降采样 + 颜色量化桶统计的方式,性能优于完整的 k-means。
/// </summary>
public static class ColorExtractorHelper
{
/// <summary>
/// 量化桶步长,将 RGB 空间按每 <see cref="BucketSize"/> 为一格分桶。
/// </summary>
private const int BucketSize = 16;
/// <summary>
/// 降采样后的最大边长(像素),用于加速取色。
/// </summary>
private const int SampleSize = 32;
/// <summary>
/// 从 BitmapImage 中提取出现频率最高的颜色。
/// </summary>
/// <param name="image">源图片。必须已调用过EndInit并完成解码。</param>
/// <returns>提取到的主色;若图片无效则返回白色。</returns>
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<long, (long Count, long R, long G, long B)>();
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;
}
/// <summary>
/// 异步版本的取色方法,将计算放到后台线程。
/// </summary>
/// <param name="image">源图片。</param>
/// <returns>提取到的主色。</returns>
public static Task<Color> ExtractDominantColorAsync(BitmapSource image)
{
if (image == null)
{
return Task.FromResult(Colors.White);
}
// BitmapImage 必须 Freeze 后才能跨线程访问像素
if (!image.IsFrozen)
{
image.Freeze();
}
return Task.Run(() => ExtractDominantColor(image));
}
/// <summary>
/// RGB → HSL 转换。
/// </summary>
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);
}
}

View File

@@ -0,0 +1,266 @@
// <copyright file="MonetPaletteHelper.cs" company="MaaAssistantArknights">
// 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
// </copyright>
using System;
using System.Collections.Generic;
using System.Windows.Media;
namespace MaaWpfGui.Helper;
/// <summary>
/// 根据提取到的主色,通过 HSL 线性计算生成 Material You 风格的主题色板。
/// <para>所有颜色均由 (色相, 饱和度, 明度) 直接计算,无离散档位量化,保证连续平滑。</para>
/// <para>文字与背景之间保持足够的明度距离以保证可读性。</para>
/// </summary>
public static class MonetPaletteHelper
{
/// <summary>
/// 需要生成的主题资源 key 列表。
/// </summary>
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%
// ── 各角色的饱和度常量 ──
/// <summary>
/// 主色Primary饱和度下限。原始主色饱和度不足此值时使用此值。
/// </summary>
private const double PrimaryMinSaturation = 0.55;
/// <summary>
/// 背景体系Neutral Tint饱和度。
/// </summary>
private const double BackgroundSaturation = 0.16;
/// <summary>
/// 文字色饱和度,极低饱和度接近中性色。
/// </summary>
private const double TextSaturation = 0.06;
// ── 对比度常量 ──
/// <summary>
/// 文字与有效背景之间要求的最小明度差。
/// 原版深色主题 PrimaryText(L=0.90) 与 Region(L=0.11) 差约 0.79。
/// </summary>
private const double TextContrastDistance = 0.70;
/// <summary>
/// 主色PrimaryBrush与有效背景之间要求的最小明度差。
/// 原版 Primary(L=0.57) 与 Region(L=0.11) 差约 0.46。
/// </summary>
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;
/// <summary>
/// RegionBrushOpacity25 中底层混合底的占比75%)。
/// Opacity25 覆盖层为 25% RegionBrush + 75% 混合底,
/// 此常量即混合底在 effectiveRegionL 公式中的权重。
/// </summary>
private const double Alpha25Factor = 0.75;
/// <summary>
/// 背景图对有效背景明度的影响因子。
/// 背景图虽然透过半透明层影响视觉,但文字的可读性主要取决于 RegionBrush 系列底色,
/// 因此只取背景图影响的 50%,避免文字色被背景图过度拉偏。
/// </summary>
private const double BackgroundInfluence = 0.5;
/// <summary>
/// 根据基础主色生成调色板。
/// </summary>
/// <param name="baseColor">提取或用户选定的主色。</param>
/// <param name="isDark">当前是否为深色模式。</param>
/// <param name="backgroundOpacity">背景图不透明度 (0~100),影响文字色的明度选取。</param>
/// <returns>资源 key → 颜色的映射。</returns>
public static Dictionary<string, Color> 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<string, Color>
{
["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;
}
/// <summary>
/// RGB → HSL 转换。
/// </summary>
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);
}
/// <summary>
/// HSL → RGB 转换。
/// </summary>
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));
}
}

View File

@@ -11,6 +11,9 @@
// but WITHOUT ANY WARRANTY
// </copyright>
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
/// <summary>
/// 缓存的莫奈调色板的生成参数,用于明暗切换后重新生成。
/// 为 null 表示莫奈取色未启用。
/// </summary>
private static Color? _monetBaseColor;
/// <summary>
/// 缓存的背景图不透明度,用于 ReapplyMonet 时重新生成文字色。
/// </summary>
private static int _monetBackgroundOpacity = 50;
/// <summary>
/// 保存被莫奈覆盖前的原始 brush用于关闭莫奈时恢复。
/// </summary>
private static readonly Dictionary<string, SolidColorBrush> _monetOriginalBrushes = [];
/// <summary>
/// 莫奈取色是否已启用(即是否已调用过 ApplyMonetPalette 且未 Revert
/// </summary>
public static bool IsMonetActive => _monetBaseColor != null;
/// <summary>
/// 获取当前是否处于深色模式。
/// </summary>
/// <returns>深色模式返回 true。</returns>
public static bool IsDarkMode()
{
return ThemeManager.Current.ApplicationTheme == ApplicationTheme.Dark;
}
/// <summary>
/// 应用莫奈调色板,覆盖主题资源中的主色系和背景色系。
/// 首次调用时会保存原始值以供回退。
/// </summary>
/// <param name="baseColor">提取或用户选定的主色。</param>
/// <param name="backgroundOpacity">背景图不透明度 (0~100),影响文字色的明度选取。</param>
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);
});
}
/// <summary>
/// 异步应用莫奈调色板:计算在后台线程,资源写入在 UI 线程。
/// 适用于滑块拖动等高频调用场景,避免阻塞 UI。
/// </summary>
/// <param name="baseColor">提取或用户选定的主色。</param>
/// <param name="backgroundOpacity">背景图不透明度 (0~100),影响文字色的明度选取。</param>
/// <param name="cancellationToken">取消令牌,用于防抖时取消过期的调色板计算。</param>
/// <returns>表示异步操作的任务。</returns>
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);
});
}
/// <summary>
/// 将已计算好的调色板写入 WPF 资源(必须在 UI 线程调用)。
/// </summary>
private static void ApplyPaletteToResources(Dictionary<string, Color> palette, Color baseColor)
{
// 首次应用时保存原始值
SaveOriginalsIfNeeded(MonetPaletteHelper.PaletteKeys);
// 先设置 AccentColorHandyControl 的 ThemeManager 会根据它重新生成
// PrimaryBrush / DarkPrimaryBrush / TitleBrush 等内部资源,
// 因此必须在 AccentColor 之后再覆盖为莫奈调色板的值,否则会被覆盖回去
ThemeManager.Current.AccentColor = new SolidColorBrush(baseColor);
foreach (var (key, color) in palette)
{
Application.Current.Resources[key] = new SolidColorBrush(color);
}
}
/// <summary>
/// 回退莫奈调色板,恢复所有被覆盖的 brush 到原始值。
/// </summary>
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;
});
}
/// <summary>
/// 当莫奈取色启用时,使用缓存的 baseColor 重新生成调色板。
/// 用于明暗模式切换后同步更新。
/// </summary>
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);
}
}
/// <summary>
/// 首次覆盖前保存原始 brush 值。
/// </summary>
private static void SaveOriginalsIfNeeded(IEnumerable<string> 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

View File

@@ -23,6 +23,12 @@
<system:String x:Key="BackgroundImageStretchModeFill">Fill</system:String>
<system:String x:Key="BackgroundImageStretchModeUniform">Uniform (Fit)</system:String>
<system:String x:Key="BackgroundImageStretchModeUniformToFill">Uniform to Fill (Cover)</system:String>
<system:String x:Key="BackgroundMonet">Monet Color</system:String>
<system:String x:Key="BackgroundMonetMode">Color Mode</system:String>
<system:String x:Key="BackgroundMonetAuto">Auto Extract</system:String>
<system:String x:Key="BackgroundMonetCustom">Custom Color</system:String>
<system:String x:Key="BackgroundMonetSelectColor">Select Color</system:String>
<system:String x:Key="BackgroundMonetAutoTip">Extract dominant color from background image to generate theme palette</system:String>
<system:String x:Key="UiTheme">UI Theme</system:String>
<system:String x:Key="HotKeySettings">HotKeys</system:String>
<system:String x:Key="UpdateSettings">Update</system:String>

View File

@@ -23,6 +23,12 @@
<system:String x:Key="BackgroundImageStretchModeFill">引き伸ばし(フィル)</system:String>
<system:String x:Key="BackgroundImageStretchModeUniform">アスペクト比保持(フィット)</system:String>
<system:String x:Key="BackgroundImageStretchModeUniformToFill">アスペクト比保持(トリミング)</system:String>
<system:String x:Key="BackgroundMonet">モネ採色</system:String>
<system:String x:Key="BackgroundMonetMode">採色モード</system:String>
<system:String x:Key="BackgroundMonetAuto">自動採色</system:String>
<system:String x:Key="BackgroundMonetCustom">カスタムカラー</system:String>
<system:String x:Key="BackgroundMonetSelectColor">色を選択</system:String>
<system:String x:Key="BackgroundMonetAutoTip">背景画像からメインカラーを自動抽出してテーマ配色を生成します</system:String>
<system:String x:Key="UiTheme">UI主題</system:String>
<system:String x:Key="HotKeySettings">ホットキー設定</system:String>
<system:String x:Key="UpdateSettings">アップデート設定</system:String>

View File

@@ -24,6 +24,12 @@
<system:String x:Key="BackgroundImageStretchModeFill">늘려 채움</system:String>
<system:String x:Key="BackgroundImageStretchModeUniform">비율 유지 (맞춤)</system:String>
<system:String x:Key="BackgroundImageStretchModeUniformToFill">비율 유지 (자르기)</system:String>
<system:String x:Key="BackgroundMonet">모네 색상</system:String>
<system:String x:Key="BackgroundMonetMode">색상 모드</system:String>
<system:String x:Key="BackgroundMonetAuto">자동 추출</system:String>
<system:String x:Key="BackgroundMonetCustom">사용자 지정 색상</system:String>
<system:String x:Key="BackgroundMonetSelectColor">색상 선택</system:String>
<system:String x:Key="BackgroundMonetAutoTip">배경 이미지에서 주요 색상을 추출하여 테마 색상을 생성합니다</system:String>
<system:String x:Key="HotKeySettings">단축키 설정</system:String>
<system:String x:Key="UpdateSettings">업데이트</system:String>
<system:String x:Key="AchievementSettings">도전과제</system:String>

View File

@@ -23,6 +23,12 @@
<system:String x:Key="BackgroundImageStretchModeFill">拉伸填充</system:String>
<system:String x:Key="BackgroundImageStretchModeUniform">等比适应</system:String>
<system:String x:Key="BackgroundImageStretchModeUniformToFill">等比填充(裁剪)</system:String>
<system:String x:Key="BackgroundMonet">莫奈取色</system:String>
<system:String x:Key="BackgroundMonetMode">取色模式</system:String>
<system:String x:Key="BackgroundMonetAuto">自动取色</system:String>
<system:String x:Key="BackgroundMonetCustom">自定义颜色</system:String>
<system:String x:Key="BackgroundMonetSelectColor">选择颜色</system:String>
<system:String x:Key="BackgroundMonetAutoTip">将从背景图自动提取主色生成主题配色</system:String>
<system:String x:Key="UiTheme">界面主题</system:String>
<system:String x:Key="HotKeySettings">热键设置</system:String>
<system:String x:Key="UpdateSettings">更新设置</system:String>

View File

@@ -23,6 +23,12 @@
<system:String x:Key="BackgroundImageStretchModeFill">非等比例填滿</system:String>
<system:String x:Key="BackgroundImageStretchModeUniform">等比例縮放</system:String>
<system:String x:Key="BackgroundImageStretchModeUniformToFill">等比例填滿(裁剪)</system:String>
<system:String x:Key="BackgroundMonet">莫奈取色</system:String>
<system:String x:Key="BackgroundMonetMode">取色模式</system:String>
<system:String x:Key="BackgroundMonetAuto">自動取色</system:String>
<system:String x:Key="BackgroundMonetCustom">自訂顏色</system:String>
<system:String x:Key="BackgroundMonetSelectColor">選擇顏色</system:String>
<system:String x:Key="BackgroundMonetAutoTip">將從背景圖自動提取主色產生主題配色</system:String>
<system:String x:Key="UiTheme">介面主題</system:String>
<system:String x:Key="HotKeySettings">熱鍵設定</system:String>
<system:String x:Key="UpdateSettings">更新設定</system:String>

View File

@@ -359,6 +359,9 @@ public class SettingsViewModel : Screen
GuiSettings.LanguageList = languageList;
GuiSettings.SwitchDarkMode();
// 主题初始化完成后,若莫奈取色已开启,恢复调色板(必须在主题切换之后执行)
BackgroundSettings.UpdateMonet();
}
private void InitConnectConfig()

View File

@@ -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<BackgroundSettingsUserControlModel>();
/// <summary>
/// 莫奈取色更新的防抖延迟(毫秒)。
/// </summary>
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<CombinedData> 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<Stretch> 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
/// <summary>
/// 莫奈取色是否启用。
/// </summary>
public bool BackgroundMonetEnabled
{
get => ConfigFactory.Root.GUI.BackgroundMonetEnabled;
set {
ConfigFactory.Root.GUI.BackgroundMonetEnabled = value;
NotifyOfPropertyChange();
UpdateMonet();
}
}
/// <summary>
/// 莫奈取色模式Auto / Custom
/// </summary>
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;
/// <summary>
/// 莫奈取色模式下拉列表。
/// </summary>
public LocalizedObservableList<MonetModeType> BackgroundMonetModeList { get; } = new(
(MonetModeType.Auto, "BackgroundMonetAuto"),
(MonetModeType.Custom, "BackgroundMonetCustom"));
/// <summary>
/// 用户自定义颜色HEX 字符串)。
/// </summary>
public string BackgroundMonetCustomColor
{
get => ConfigFactory.Root.GUI.BackgroundMonetCustomColor;
set {
ConfigFactory.Root.GUI.BackgroundMonetCustomColor = value;
NotifyOfPropertyChange();
NotifyOfPropertyChange(nameof(CurrentMonetColor));
}
}
/// <summary>
/// 当前生效的莫奈主色,用于色块预览。
/// </summary>
public Color CurrentMonetColor
{
get {
if (!BackgroundMonetEnabled)
{
// 关闭时显示默认主色
return ThemeHelper.String2Color("#326CF3");
}
return IsBackgroundMonetCustom
? ThemeHelper.String2Color(BackgroundMonetCustomColor)
: _lastExtractedColor ?? ThemeHelper.String2Color("#326CF3");
}
}
/// <summary>
/// 上次自动提取到的颜色(缓存)。
/// </summary>
private Color? _lastExtractedColor;
/// <summary>
/// 打开 HandyControl ColorPicker 弹窗让用户选择自定义颜色。
/// </summary>
public void SelectMonetColor()
{
var picker = SingleOpenHelper.CreateControl<ColorPicker>();
// 初始化 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();
}
/// <summary>
/// 防抖取消令牌源,避免滑块拖动时高频重新计算调色板。
/// </summary>
private CancellationTokenSource? _monetUpdateCts;
/// <summary>
/// 调度一次莫奈取色更新(防抖 150ms
/// 滑块拖动等高频场景调用此方法,而非直接 <see cref="UpdateMonet"/>。
/// </summary>
private void ScheduleMonetUpdate()
{
_monetUpdateCts?.Cancel();
_monetUpdateCts = new CancellationTokenSource();
_ = UpdateMonetAsync(_monetUpdateCts.Token);
}
/// <summary>
/// 根据当前配置执行莫奈取色逻辑。
/// 自动 / 自定义模式都会将计算放到后台线程,仅资源写入在 UI 线程。
/// </summary>
public void UpdateMonet()
{
_monetUpdateCts?.Cancel();
_ = UpdateMonetAsync(CancellationToken.None);
}
/// <summary>
/// 异步执行莫奈取色,支持取消(用于防抖)。
/// 所有异常均在方法内部捕获并记录,避免 fire-and-forget 调用产生未观察异常。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
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.");
}
}
/// <summary>
/// 异步执行自动取色:提取主色 → 应用调色板。
/// 颜色提取和调色板计算在后台线程,资源写入在 UI 线程。
/// </summary>
/// <param name="cancellationToken">取消令牌。</param>
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
}

View File

@@ -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">
<StackPanel>
<StackPanel Margin="10">
<StackPanel Margin="5">
<controls:TextBlock
Margin="0,0,0,5"
HorizontalAlignment="Center"
@@ -40,7 +40,7 @@
</Grid>
</StackPanel>
<StackPanel
Margin="10"
Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<controls:TextBlock
@@ -60,7 +60,7 @@
</hc:PreviewSlider>
</StackPanel>
<StackPanel
Margin="10"
Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<controls:TextBlock
@@ -80,7 +80,7 @@
</hc:PreviewSlider>
</StackPanel>
<StackPanel
Margin="10"
Margin="5"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<controls:TextBlock
@@ -93,9 +93,67 @@
DisplayMemberPath="Display"
IsEditable="True"
IsEnabled="True"
ItemsSource="{c:Binding BackgroundImageStretchModeList}"
SelectedValue="{c:Binding BackgroundImageStretchMode}"
ItemsSource="{Binding BackgroundImageStretchModeList}"
SelectedValue="{Binding BackgroundImageStretchMode}"
SelectedValuePath="Value" />
</StackPanel>
<StackPanel HorizontalAlignment="Center">
<CheckBox
Margin="5"
HorizontalAlignment="Center"
Content="{DynamicResource BackgroundMonet}"
IsChecked="{Binding BackgroundMonetEnabled}" />
<StackPanel
Margin="5"
IsEnabled="{Binding BackgroundMonetEnabled}"
Visibility="{c:Binding BackgroundMonetEnabled}">
<!-- 取色模式选择 -->
<controls:TextBlock
Margin="0,0,0,5"
HorizontalAlignment="Center"
Text="{DynamicResource BackgroundMonetMode}" />
<hc:ComboBox
Width="200"
Margin="0,0,0,5"
HorizontalContentAlignment="Stretch"
DisplayMemberPath="Display"
ItemsSource="{Binding BackgroundMonetModeList}"
SelectedValue="{Binding BackgroundMonetMode}"
SelectedValuePath="Value" />
<!-- 自动模式提示 -->
<controls:TextBlock
HorizontalAlignment="Center"
FontSize="11"
Opacity="0.6"
Text="{DynamicResource BackgroundMonetAutoTip}"
TextWrapping="Wrap"
Visibility="{c:Binding IsBackgroundMonetAuto}" />
<!-- 自定义颜色选择 -->
<StackPanel
HorizontalAlignment="Center"
Orientation="Horizontal"
Visibility="{c:Binding IsBackgroundMonetCustom}">
<!-- 色块预览 -->
<Rectangle
Width="32"
Height="32"
Margin="0,0,10,0"
VerticalAlignment="Center"
RadiusX="4"
RadiusY="4"
Stroke="{DynamicResource RegionBrushOpacity50}">
<Rectangle.Fill>
<SolidColorBrush d:Color="{DynamicResource PrimaryColor}" Color="{Binding CurrentMonetColor}" />
</Rectangle.Fill>
</Rectangle>
<Button
hc:BorderElement.CornerRadius="4"
Command="{s:Action SelectMonetColor}"
Content="{DynamicResource BackgroundMonetSelectColor}" />
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
</UserControl>