diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs new file mode 100644 index 0000000..892b7df --- /dev/null +++ b/KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs @@ -0,0 +1,61 @@ +using System; +using System.Windows.Media; +using KeyStats.Helpers; +using KeyStats.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace KeyStats.Sync.Tests; + +[TestClass] +public sealed class HeatmapPaletteTests +{ + [TestMethod] + public void CustomPeakColorsRemainReadableInBothThemes() + { + foreach (var dark in new[] { false, true }) + { + foreach (var peak in new[] { Colors.White, Colors.Black }) + { + var fill = HeatmapPalette.ColorFor(AppSettings.HeatmapColorSchemeCustom, + Colors.Gray, peak, 1, dark); + Assert.AreEqual(peak, fill); + Assert.AreEqual(peak == Colors.White ? Colors.Black : Colors.White, + HeatmapPalette.LegendColor(fill)); + } + } + } + + [TestMethod] + public void PresetAndCustomGradientsMaintainLegibleContrast() + { + var schemes = new[] + { + AppSettings.HeatmapColorSchemeDefault, AppSettings.HeatmapColorSchemeWarm, + AppSettings.HeatmapColorSchemeSpectrum, AppSettings.HeatmapColorSchemeGreen, + AppSettings.HeatmapColorSchemeTeal, AppSettings.HeatmapColorSchemePurple, + AppSettings.HeatmapColorSchemeRose, AppSettings.HeatmapColorSchemeCustom + }; + foreach (var dark in new[] { false, true }) + foreach (var scheme in schemes) + for (var step = 0; step <= 10; step++) + { + var fill = HeatmapPalette.ColorFor(scheme, Colors.White, Colors.Black, step / 10.0, dark); + var foreground = HeatmapPalette.LegendColor(fill); + var backgroundLuminance = Luminance(fill); + var foregroundLuminance = Luminance(foreground); + var contrast = (Math.Max(backgroundLuminance, foregroundLuminance) + 0.05) + / (Math.Min(backgroundLuminance, foregroundLuminance) + 0.05); + Assert.IsTrue(contrast >= 4.5, $"{scheme}, dark={dark}, step={step}: {contrast}"); + } + } + + private static double Luminance(Color color) + { + double Linear(byte component) + { + var value = component / 255.0; + return value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4); + } + return 0.2126 * Linear(color.R) + 0.7152 * Linear(color.G) + 0.0722 * Linear(color.B); + } +} diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj index 99fc3e6..fd0320b 100644 --- a/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj +++ b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj @@ -2,6 +2,7 @@ net48 + true 10.0 enable disable diff --git a/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs new file mode 100644 index 0000000..d662664 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using KeyStats.Models; +using KeyStats.Services; + +namespace KeyStats.Helpers; + +/// +/// 键盘热力图配色:预置色板 + 用户自定义起止色。热力图控件和设置页预览共用同一套取色逻辑。 +/// +public static class HeatmapPalette +{ + private sealed class Preset + { + public string Id { get; } + public Color[] LightStops { get; } + public Color[] DarkStops { get; } + + public Preset(string id, Color[] lightStops, Color[] darkStops) + { + Id = id; + LightStops = lightStops; + DarkStops = darkStops; + } + } + + // 每个预置色板三个色阶:低频 → 中频 → 高频。深色模式下高频键更亮,浅色模式下更深。 + private static readonly IReadOnlyList Presets = new[] + { + new Preset(AppSettings.HeatmapColorSchemeWarm, + new[] { Rgb(255, 196, 92), Rgb(251, 140, 60), Rgb(229, 57, 53) }, + new[] { Rgb(196, 132, 40), Rgb(226, 110, 38), Rgb(222, 64, 56) }), + new Preset(AppSettings.HeatmapColorSchemeSpectrum, + new[] { Rgb(100, 181, 246), Rgb(255, 202, 40), Rgb(229, 57, 53) }, + new[] { Rgb(30, 136, 229), Rgb(253, 216, 53), Rgb(239, 83, 80) }), + new Preset(AppSettings.HeatmapColorSchemeGreen, + new[] { Rgb(129, 199, 132), Rgb(67, 160, 71), Rgb(27, 94, 32) }, + new[] { Rgb(46, 125, 50), Rgb(67, 160, 71), Rgb(129, 199, 132) }), + new Preset(AppSettings.HeatmapColorSchemeTeal, + new[] { Rgb(128, 222, 234), Rgb(0, 172, 193), Rgb(0, 96, 100) }, + new[] { Rgb(0, 96, 100), Rgb(0, 151, 167), Rgb(77, 208, 225) }), + new Preset(AppSettings.HeatmapColorSchemePurple, + new[] { Rgb(206, 147, 216), Rgb(142, 36, 170), Rgb(74, 20, 140) }, + new[] { Rgb(106, 27, 154), Rgb(156, 39, 176), Rgb(206, 147, 216) }), + new Preset(AppSettings.HeatmapColorSchemeRose, + new[] { Rgb(248, 187, 208), Rgb(236, 64, 122), Rgb(173, 20, 87) }, + new[] { Rgb(136, 14, 79), Rgb(216, 27, 96), Rgb(244, 143, 177) }), + }; + + public static readonly Color DefaultCustomLowColor = Rgb(255, 224, 130); + public static readonly Color DefaultCustomHighColor = Rgb(198, 40, 40); + + public static event Action? SchemeChanged; + + public static void NotifySchemeChanged() + { + SchemeChanged?.Invoke(); + } + + /// 按键次数为 0 时的底色。 + public static Color NeutralColor(bool isDarkMode) + { + var surface = ResolveColor("SurfaceBrush", isDarkMode ? Color.FromRgb(32, 32, 32) : Color.FromRgb(250, 250, 250)); + var divider = ResolveColor("DividerBrush", isDarkMode ? Color.FromRgb(61, 61, 61) : Color.FromRgb(229, 229, 229)); + return Blend(surface, divider, isDarkMode ? 0.22 : 0.10); + } + + /// 按当前设置取色,intensity 为 0~1 的热度。 + public static Color ColorFor(double intensity, bool isDarkMode) + { + var settings = StatsManager.Instance.Settings; + return ColorFor( + settings.HeatmapColorScheme, + ParseColor(settings.HeatmapCustomLowColor, DefaultCustomLowColor), + ParseColor(settings.HeatmapCustomHighColor, DefaultCustomHighColor), + intensity, + isDarkMode); + } + + public static Color ColorFor(string? scheme, Color customLow, Color customHigh, double intensity, bool isDarkMode) + { + var neutral = NeutralColor(isDarkMode); + + if (string.Equals(scheme, AppSettings.HeatmapColorSchemeCustom, StringComparison.Ordinal)) + { + return ThreeStopColor(neutral, customLow, Blend(customLow, customHigh, 0.5), customHigh, intensity, isDarkMode); + } + + var preset = Presets.FirstOrDefault(p => string.Equals(p.Id, scheme, StringComparison.Ordinal)); + if (preset != null) + { + var stops = isDarkMode ? preset.DarkStops : preset.LightStops; + return ThreeStopColor(neutral, stops[0], stops[1], stops[2], intensity, isDarkMode); + } + + // 默认:跟随系统强调色(保持原有配色不变) + var accent = ResolveColor("AccentBrush", isDarkMode ? Color.FromRgb(0, 120, 212) : Color.FromRgb(0, 103, 192)); + var low = Blend(neutral, accent, isDarkMode ? 0.34 : 0.24); + var high = Blend(accent, isDarkMode ? Colors.White : Colors.Black, isDarkMode ? 0.18 : 0.10); + return Blend(low, high, intensity); + } + + /// 设置页下拉项里的色条缩略图。 + public static Brush CreatePreviewBrush(string scheme, Color customLow, Color customHigh, bool isDarkMode) + { + var brush = new LinearGradientBrush { StartPoint = new Point(0, 0.5), EndPoint = new Point(1, 0.5) }; + const int steps = 6; + for (var i = 0; i <= steps; i++) + { + var t = i / (double)steps; + brush.GradientStops.Add(new GradientStop(ColorFor(scheme, customLow, customHigh, Math.Max(0.05, t), isDarkMode), t)); + } + brush.Freeze(); + return brush; + } + + public static Color ParseColor(string? hex, Color fallback) + { + if (string.IsNullOrWhiteSpace(hex)) + { + return fallback; + } + + try + { + return ColorConverter.ConvertFromString(hex) is Color color ? color : fallback; + } + catch (FormatException) + { + return fallback; + } + } + + /// 为实际键帽底色选择对比度更高的黑色或白色文字。 + public static Color LegendColor(Color background) + { + double Linear(byte component) + { + var value = component / 255.0; + return value <= 0.04045 ? value / 12.92 : Math.Pow((value + 0.055) / 1.055, 2.4); + } + + var luminance = 0.2126 * Linear(background.R) + + 0.7152 * Linear(background.G) + + 0.0722 * Linear(background.B); + var blackContrast = (luminance + 0.05) / 0.05; + var whiteContrast = 1.05 / (luminance + 0.05); + return blackContrast >= whiteContrast ? Colors.Black : Colors.White; + } + + public static string ToHex(Color color) + { + return string.Format(CultureInfo.InvariantCulture, "#{0:X2}{1:X2}{2:X2}", color.R, color.G, color.B); + } + + public static Color Blend(Color from, Color to, double t) + { + var progress = Math.Max(0, Math.Min(1, t)); + return Color.FromArgb( + (byte)Math.Round(from.A + (to.A - from.A) * progress), + (byte)Math.Round(from.R + (to.R - from.R) * progress), + (byte)Math.Round(from.G + (to.G - from.G) * progress), + (byte)Math.Round(from.B + (to.B - from.B) * progress)); + } + + public static Color ResolveColor(string key, Color fallback) + { + var resource = Application.Current?.Resources[key]; + if (resource is SolidColorBrush brush) + { + return brush.Color; + } + if (resource is Color color) + { + return color; + } + return fallback; + } + + private static Color ThreeStopColor(Color neutral, Color lowStop, Color midStop, Color highStop, double intensity, bool isDarkMode) + { + // 低频键与底色混合,避免只按过一两次的键过于抢眼 + var low = Blend(neutral, lowStop, isDarkMode ? 0.45 : 0.40); + return intensity < 0.5 + ? Blend(low, midStop, intensity / 0.5) + : Blend(midStop, highStop, (intensity - 0.5) / 0.5); + } + + private static Color Rgb(byte r, byte g, byte b) => Color.FromRgb(r, g, b); +} diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index d58f5e2..4319165 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -12,6 +12,14 @@ public class AppSettings public const int DefaultFloatingStatsFontSize = 12; public const int MinimumFloatingStatsFontSize = 9; public const int MaximumFloatingStatsFontSize = 22; + public const string HeatmapColorSchemeDefault = "default"; + public const string HeatmapColorSchemeWarm = "warm"; + public const string HeatmapColorSchemeSpectrum = "spectrum"; + public const string HeatmapColorSchemeGreen = "green"; + public const string HeatmapColorSchemeTeal = "teal"; + public const string HeatmapColorSchemePurple = "purple"; + public const string HeatmapColorSchemeRose = "rose"; + public const string HeatmapColorSchemeCustom = "custom"; private int _floatingStatsFontSize = DefaultFloatingStatsFontSize; [JsonPropertyName("notificationsEnabled")] @@ -101,6 +109,15 @@ public int FloatingStatsFontSize [JsonPropertyName("floatingStatsPositionLocked")] public bool FloatingStatsPositionLocked { get; set; } + [JsonPropertyName("heatmapColorScheme")] + public string HeatmapColorScheme { get; set; } = HeatmapColorSchemeDefault; // "default" | "warm" | "spectrum" | "green" | "teal" | "purple" | "rose" | "custom" + + [JsonPropertyName("heatmapCustomLowColor")] + public string HeatmapCustomLowColor { get; set; } = "#FFE082"; + + [JsonPropertyName("heatmapCustomHighColor")] + public string HeatmapCustomHighColor { get; set; } = "#C62828"; + [JsonPropertyName("languagePreference")] public string LanguagePreference { get; set; } = "system"; // "system" | "zh-Hans" | "zh-Hant" | "en" } diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.cs b/KeyStats.Windows/KeyStats/Properties/Strings.cs index 9de5a5a..b359298 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.cs +++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs @@ -52,6 +52,20 @@ public static class Strings public static string Dialog_ImportTitle => Get(nameof(Dialog_ImportTitle)); public static string Dialog_JsonFilter => Get(nameof(Dialog_JsonFilter)); + public static string Settings_HeatmapColorScheme => Get(nameof(Settings_HeatmapColorScheme)); + public static string Settings_HeatmapColorSchemeDesc => Get(nameof(Settings_HeatmapColorSchemeDesc)); + public static string Settings_HeatmapColorScheme_Default => Get(nameof(Settings_HeatmapColorScheme_Default)); + public static string Settings_HeatmapColorScheme_Warm => Get(nameof(Settings_HeatmapColorScheme_Warm)); + public static string Settings_HeatmapColorScheme_Spectrum => Get(nameof(Settings_HeatmapColorScheme_Spectrum)); + public static string Settings_HeatmapColorScheme_Green => Get(nameof(Settings_HeatmapColorScheme_Green)); + public static string Settings_HeatmapColorScheme_Teal => Get(nameof(Settings_HeatmapColorScheme_Teal)); + public static string Settings_HeatmapColorScheme_Purple => Get(nameof(Settings_HeatmapColorScheme_Purple)); + public static string Settings_HeatmapColorScheme_Rose => Get(nameof(Settings_HeatmapColorScheme_Rose)); + public static string Settings_HeatmapColorScheme_Custom => Get(nameof(Settings_HeatmapColorScheme_Custom)); + public static string Settings_HeatmapCustomLow => Get(nameof(Settings_HeatmapCustomLow)); + public static string Settings_HeatmapCustomHigh => Get(nameof(Settings_HeatmapCustomHigh)); + public static string Settings_HeatmapPreviewLess => Get(nameof(Settings_HeatmapPreviewLess)); + public static string Settings_HeatmapPreviewMore => Get(nameof(Settings_HeatmapPreviewMore)); public static string Settings_Language => Get(nameof(Settings_Language)); public static string Settings_LanguageDescription => Get(nameof(Settings_LanguageDescription)); public static string Settings_Language_System => Get(nameof(Settings_Language_System)); diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.resx b/KeyStats.Windows/KeyStats/Properties/Strings.resx index b4f5e98..8a008a5 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx @@ -109,6 +109,20 @@ Language Display language for the app interface System + Heatmap Colors + Color palette for the keyboard heatmap + Default (Blue) + Warm (Orange-Red) + Spectrum (Blue-Yellow-Red) + Green + Teal + Purple + Rose + Custom + Low color + High color + Less + More Restart Required KeyStats needs to restart to apply the new language. Restart now? diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx index 0f7a969..42b4f4e 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx @@ -109,6 +109,20 @@ 语言 应用界面显示语言 跟随系统 + 热力图配色 + 键盘热力图的按键颜色 + 默认(蓝色) + 暖色(橙红) + 光谱(蓝黄红) + 绿色 + 青色 + 紫色 + 玫红 + 自定义 + 起始色 + 最高色 + 少 + 多 需要重启 需要重启 KeyStats 才能切换语言。是否立即重启? diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx index d041a3c..655f800 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx @@ -109,6 +109,20 @@ 語言 應用程式介面的顯示語言 跟隨系統 + 熱力圖配色 + 鍵盤熱力圖的按鍵顏色 + 預設(藍色) + 暖色(橙紅) + 光譜(藍黃紅) + 綠色 + 青色 + 紫色 + 玫紅 + 自訂 + 起始色 + 最高色 + 少 + 多 需要重新啟動 KeyStats 需要重新啟動才能套用新的語言。要立即重新啟動嗎? diff --git a/KeyStats.Windows/KeyStats/Views/Controls/HeatmapPalettePreviewControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/HeatmapPalettePreviewControl.cs new file mode 100644 index 0000000..a4424e2 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Views/Controls/HeatmapPalettePreviewControl.cs @@ -0,0 +1,76 @@ +using System; +using System.Windows; +using System.Windows.Media; +using KeyStats.Helpers; + +namespace KeyStats.Views.Controls; + +/// +/// 设置页里的热力图色阶预览:从「未按过」到最高热度的一排小键帽。 +/// +public class HeatmapPalettePreviewControl : FrameworkElement +{ + private const int CellCount = 9; + private const double CellGap = 4; + + public HeatmapPalettePreviewControl() + { + SnapsToDevicePixels = true; + UseLayoutRounding = true; + Height = 22; + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + protected override void OnRender(DrawingContext drawingContext) + { + base.OnRender(drawingContext); + + if (ActualWidth <= 0 || ActualHeight <= 0) + { + return; + } + + var isDarkMode = ThemeManager.Instance.IsDarkTheme; + var borderColor = isDarkMode + ? Color.FromArgb(36, 255, 255, 255) + : Color.FromArgb(23, 0, 0, 0); + var borderPen = new Pen(new SolidColorBrush(borderColor), 0.8); + borderPen.Freeze(); + + var cellWidth = (ActualWidth - CellGap * (CellCount - 1)) / CellCount; + if (cellWidth <= 0) + { + return; + } + + var radius = Math.Min(4, ActualHeight * 0.2); + for (var i = 0; i < CellCount; i++) + { + var color = i == 0 + ? HeatmapPalette.NeutralColor(isDarkMode) + : HeatmapPalette.ColorFor(i / (double)(CellCount - 1), isDarkMode); + var brush = new SolidColorBrush(color); + brush.Freeze(); + var rect = new Rect(i * (cellWidth + CellGap), 0, cellWidth, ActualHeight); + drawingContext.DrawRoundedRectangle(brush, borderPen, rect, radius, radius); + } + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + ThemeManager.Instance.ThemeChanged += OnPaletteChanged; + HeatmapPalette.SchemeChanged += OnPaletteChanged; + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + ThemeManager.Instance.ThemeChanged -= OnPaletteChanged; + HeatmapPalette.SchemeChanged -= OnPaletteChanged; + } + + private void OnPaletteChanged() + { + Dispatcher.BeginInvoke(new Action(InvalidateVisual)); + } +} diff --git a/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs index 62a5c0f..4d5c54f 100644 --- a/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs +++ b/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs @@ -75,8 +75,6 @@ protected override void OnRender(DrawingContext drawingContext) } var isDarkMode = ThemeManager.Instance.IsDarkTheme; - var defaultTextColor = isDarkMode ? Colors.White : ResolveColor("TextPrimaryBrush", Colors.Black); - var textBrush = CreateBrush(defaultTextColor); var borderColor = isDarkMode ? Color.FromArgb(36, 255, 255, 255) : Color.FromArgb(23, 0, 0, 0); @@ -88,10 +86,12 @@ protected override void OnRender(DrawingContext drawingContext) var frame = KeyFrame(key, metrics); var count = _keyCounts.TryGetValue(key.Id, out var value) ? value : 0; var fillColor = ColorForCount(count, isDarkMode); + var textColor = HeatmapPalette.LegendColor(fillColor); + var textBrush = CreateBrush(textColor); var radius = Math.Max(4, metrics.Scale * 0.14); drawingContext.DrawRoundedRectangle(CreateBrush(fillColor), borderPen, frame, radius, radius); - DrawKeyLegend(drawingContext, key, frame, metrics.Scale, textBrush, defaultTextColor); + DrawKeyLegend(drawingContext, key, frame, metrics.Scale, textBrush, textColor); DrawCountBadge(drawingContext, count, frame, metrics.Scale, isDarkMode); } } @@ -99,11 +99,13 @@ protected override void OnRender(DrawingContext drawingContext) private void OnLoaded(object sender, RoutedEventArgs e) { ThemeManager.Instance.ThemeChanged += OnThemeChanged; + HeatmapPalette.SchemeChanged += OnThemeChanged; } private void OnUnloaded(object sender, RoutedEventArgs e) { ThemeManager.Instance.ThemeChanged -= OnThemeChanged; + HeatmapPalette.SchemeChanged -= OnThemeChanged; } private void OnThemeChanged() @@ -334,13 +336,9 @@ private static string CompactUnit(double value, string suffix) private Color ColorForCount(int count, bool isDarkMode) { - var surface = ResolveColor("SurfaceBrush", isDarkMode ? Color.FromRgb(32, 32, 32) : Color.FromRgb(250, 250, 250)); - var divider = ResolveColor("DividerBrush", isDarkMode ? Color.FromRgb(61, 61, 61) : Color.FromRgb(229, 229, 229)); - var neutral = Blend(surface, divider, isDarkMode ? 0.22 : 0.10); - if (count <= 0) { - return neutral; + return HeatmapPalette.NeutralColor(isDarkMode); } var normalized = _maxCount > 0 @@ -348,11 +346,7 @@ private Color ColorForCount(int count, bool isDarkMode) : 0; var eased = Math.Pow(normalized, 0.82); - var accent = ResolveColor("AccentBrush", isDarkMode ? Color.FromRgb(0, 120, 212) : Color.FromRgb(0, 103, 192)); - var low = Blend(neutral, accent, isDarkMode ? 0.34 : 0.24); - var high = Blend(accent, isDarkMode ? Colors.White : Colors.Black, isDarkMode ? 0.18 : 0.10); - - return Blend(low, high, eased); + return HeatmapPalette.ColorFor(eased, isDarkMode); } private static Color ResolveColor(string key, Color fallback) @@ -369,16 +363,6 @@ private static Color ResolveColor(string key, Color fallback) return fallback; } - private static Color Blend(Color from, Color to, double t) - { - var progress = Math.Max(0, Math.Min(1, t)); - return Color.FromArgb( - (byte)Math.Round(from.A + (to.A - from.A) * progress), - (byte)Math.Round(from.R + (to.R - from.R) * progress), - (byte)Math.Round(from.G + (to.G - from.G) * progress), - (byte)Math.Round(from.B + (to.B - from.B) * progress)); - } - private static SolidColorBrush CreateBrush(Color color) { var brush = new SolidColorBrush(color); diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml index 3c28d7c..ee4ac00 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:p="clr-namespace:KeyStats.Properties" + xmlns:controls="clr-namespace:KeyStats.Views.Controls" Title="{x:Static p:Strings.Settings_WindowTitle}" Width="420" SizeToContent="Height" WindowStartupLocation="CenterScreen" @@ -206,6 +207,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs index c2f4245..ed2a64d 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs @@ -35,6 +35,7 @@ private void OnLoaded(object sender, RoutedEventArgs e) UpdateMaximumHeight(); ApplyWindowBackdrop(); LoadFloatingStatsControls(); + LoadHeatmapColorSchemeControls(); if (App.CurrentApp?.SyncCoordinator != null) { App.CurrentApp.SyncCoordinator.StatusChanged += OnSyncStatusChanged; @@ -55,7 +56,12 @@ private void OnClosed(object? sender, System.EventArgs e) private void OnThemeChanged() { - Dispatcher.BeginInvoke(new System.Action(ApplyWindowBackdrop)); + Dispatcher.BeginInvoke(new System.Action(() => + { + ApplyWindowBackdrop(); + // 下拉项色条按深浅色主题分别生成,主题切换后重建 + LoadHeatmapColorSchemeControls(); + })); } private void ApplyWindowBackdrop() @@ -344,6 +350,118 @@ private void OpenGitHub_Click(object sender, RoutedEventArgs e) } } + private bool _isLoadingHeatmapColorScheme = true; + + private void LoadHeatmapColorSchemeControls() + { + _isLoadingHeatmapColorScheme = true; + var settings = StatsManager.Instance.Settings; + var isDarkMode = ThemeManager.Instance.IsDarkTheme; + var customLow = HeatmapPalette.ParseColor(settings.HeatmapCustomLowColor, HeatmapPalette.DefaultCustomLowColor); + var customHigh = HeatmapPalette.ParseColor(settings.HeatmapCustomHighColor, HeatmapPalette.DefaultCustomHighColor); + + HeatmapColorSchemeComboBox.ItemsSource = new[] + { + (AppSettings.HeatmapColorSchemeDefault, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Default), + (AppSettings.HeatmapColorSchemeWarm, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Warm), + (AppSettings.HeatmapColorSchemeSpectrum, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Spectrum), + (AppSettings.HeatmapColorSchemeGreen, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Green), + (AppSettings.HeatmapColorSchemeTeal, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Teal), + (AppSettings.HeatmapColorSchemePurple, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Purple), + (AppSettings.HeatmapColorSchemeRose, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Rose), + (AppSettings.HeatmapColorSchemeCustom, KeyStats.Properties.Strings.Settings_HeatmapColorScheme_Custom), + } + .Select(option => new HeatmapSchemeOption( + option.Item1, + option.Item2, + HeatmapPalette.CreatePreviewBrush(option.Item1, customLow, customHigh, isDarkMode))) + .ToList(); + HeatmapColorSchemeComboBox.SelectedValue = settings.HeatmapColorScheme; + if (HeatmapColorSchemeComboBox.SelectedIndex < 0) + { + HeatmapColorSchemeComboBox.SelectedIndex = 0; + } + + HeatmapCustomLowSwatch.Background = new SolidColorBrush(customLow); + HeatmapCustomHighSwatch.Background = new SolidColorBrush(customHigh); + HeatmapCustomColorsPanel.Visibility = settings.HeatmapColorScheme == AppSettings.HeatmapColorSchemeCustom + ? Visibility.Visible + : Visibility.Collapsed; + _isLoadingHeatmapColorScheme = false; + } + + private void HeatmapColorSchemeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_isLoadingHeatmapColorScheme || HeatmapColorSchemeComboBox.SelectedValue is not string scheme) + { + return; + } + + var settings = StatsManager.Instance.Settings; + if (scheme == settings.HeatmapColorScheme) + { + return; + } + + settings.HeatmapColorScheme = scheme; + StatsManager.Instance.SaveSettings(); + HeatmapCustomColorsPanel.Visibility = scheme == AppSettings.HeatmapColorSchemeCustom + ? Visibility.Visible + : Visibility.Collapsed; + HeatmapPalette.NotifySchemeChanged(); + App.CurrentApp?.TrackClick("settings_heatmap_color_scheme", new System.Collections.Generic.Dictionary + { + ["scheme"] = scheme + }); + } + + private void HeatmapCustomLowColor_Click(object sender, RoutedEventArgs e) + { + PickHeatmapCustomColor(isLow: true); + } + + private void HeatmapCustomHighColor_Click(object sender, RoutedEventArgs e) + { + PickHeatmapCustomColor(isLow: false); + } + + private void PickHeatmapCustomColor(bool isLow) + { + var settings = StatsManager.Instance.Settings; + var current = isLow + ? HeatmapPalette.ParseColor(settings.HeatmapCustomLowColor, HeatmapPalette.DefaultCustomLowColor) + : HeatmapPalette.ParseColor(settings.HeatmapCustomHighColor, HeatmapPalette.DefaultCustomHighColor); + + using var dialog = new Forms.ColorDialog + { + AnyColor = true, + FullOpen = true, + Color = System.Drawing.Color.FromArgb(current.R, current.G, current.B) + }; + if (dialog.ShowDialog(new Win32WindowOwner(new WindowInteropHelper(this).Handle)) != Forms.DialogResult.OK) + { + return; + } + + var hex = HeatmapPalette.ToHex(Color.FromRgb(dialog.Color.R, dialog.Color.G, dialog.Color.B)); + if (isLow) + { + settings.HeatmapCustomLowColor = hex; + } + else + { + settings.HeatmapCustomHighColor = hex; + } + StatsManager.Instance.SaveSettings(); + // 刷新下拉项里「自定义」的色条和色块 + LoadHeatmapColorSchemeControls(); + HeatmapPalette.NotifySchemeChanged(); + App.CurrentApp?.TrackClick("settings_heatmap_custom_color", new System.Collections.Generic.Dictionary + { + ["slot"] = isLow ? "low" : "high" + }); + } + private bool _isInitializingLanguage = true; private void LanguageComboBox_Loaded(object sender, RoutedEventArgs e) @@ -417,6 +535,32 @@ private static void RestartApp() Application.Current.Shutdown(); } + private sealed class HeatmapSchemeOption + { + public HeatmapSchemeOption(string id, string label, Brush previewBrush) + { + Id = id; + Label = label; + PreviewBrush = previewBrush; + } + + public string Id { get; } + + public string Label { get; } + + public Brush PreviewBrush { get; } + } + + private sealed class Win32WindowOwner : Forms.IWin32Window + { + public Win32WindowOwner(System.IntPtr handle) + { + Handle = handle; + } + + public System.IntPtr Handle { get; } + } + private sealed class FloatingMetricOption { public FloatingMetricOption(string id, string label) diff --git a/KeyStats/KeyboardHeatmapViewController.swift b/KeyStats/KeyboardHeatmapViewController.swift index 689d9c1..584a359 100644 --- a/KeyStats/KeyboardHeatmapViewController.swift +++ b/KeyStats/KeyboardHeatmapViewController.swift @@ -994,10 +994,30 @@ private final class KeyboardHeatmapView: NSView { return formatter }() + private var colorSchemeObserver: NSObjectProtocol? + override var isFlipped: Bool { true } + deinit { + if let observer = colorSchemeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard window != nil, colorSchemeObserver == nil else { return } + colorSchemeObserver = NotificationCenter.default.addObserver( + forName: .heatmapColorSchemeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.needsDisplay = true + } + } + func apply(keyCounts: [String: Int]) { self.keyCounts = keyCounts self.maxCount = keyCounts.values.max() ?? 0 @@ -1010,12 +1030,12 @@ private final class KeyboardHeatmapView: NSView { guard let metrics = renderMetrics() else { return } let isDarkMode = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - let labelColor: NSColor = isDarkMode ? .white : .labelColor for key in Self.layout { let frame = keyFrame(for: key, metrics: metrics) let count = count(for: key) let fillColor = color(for: count, isDarkMode: isDarkMode) + let labelColor = HeatmapPalette.legendColor(on: fillColor) let keyPath = NSBezierPath(roundedRect: frame, xRadius: max(4, metrics.scale * 0.14), yRadius: max(4, metrics.scale * 0.14)) fillColor.setFill() @@ -1112,7 +1132,8 @@ private final class KeyboardHeatmapView: NSView { ) if let symbolImage = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)? - .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: symbolWeight)) { + .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: symbolWeight) + .applying(NSImage.SymbolConfiguration(paletteColors: [textColor]))) { symbolImage.draw(in: symbolRect) } @@ -1296,14 +1317,8 @@ private final class KeyboardHeatmapView: NSView { } private func color(for count: Int, isDarkMode: Bool) -> NSColor { - let surfaceColor = isDarkMode ? NSColor.windowBackgroundColor : NSColor.controlBackgroundColor - let neutralColor = surfaceColor.blended(withFraction: isDarkMode ? 0.22 : 0.10, of: .separatorColor) - ?? (isDarkMode - ? NSColor(calibratedRed: 0.20, green: 0.22, blue: 0.25, alpha: 1) - : NSColor(calibratedRed: 0.93, green: 0.94, blue: 0.96, alpha: 1)) - if count <= 0 { - return neutralColor + return HeatmapPalette.neutralColor(isDarkMode: isDarkMode) } let normalized = maxCount > 0 @@ -1311,16 +1326,172 @@ private final class KeyboardHeatmapView: NSView { : 0 let eased = pow(normalized, 0.82) - let accentColor = NSColor.controlAccentColor - let lowColor = neutralColor.blended(withFraction: isDarkMode ? 0.34 : 0.24, of: accentColor) - ?? blendColor(from: neutralColor, to: accentColor, t: isDarkMode ? 0.34 : 0.24) - let highColor = accentColor.blended(withFraction: isDarkMode ? 0.18 : 0.10, of: isDarkMode ? .white : .black) - ?? blendColor(from: accentColor, to: isDarkMode ? .white : .black, t: isDarkMode ? 0.18 : 0.10) + return HeatmapPalette.color(intensity: eased, isDarkMode: isDarkMode) + } +} + +// MARK: - Heatmap Palette + +extension Notification.Name { + static let heatmapColorSchemeDidChange = Notification.Name("heatmapColorSchemeDidChange") +} + +enum HeatmapColorScheme: String, CaseIterable { + case `default` + case warm + case spectrum + case green + case teal + case purple + case rose + case custom + + var localizedTitle: String { + NSLocalizedString("settings.heatmapColorScheme.\(rawValue)", comment: "") + } +} + +/// 键盘热力图配色:预置色板 + 用户自定义起止色。色值与 Windows 端 HeatmapPalette.cs 保持一致。 +enum HeatmapPalette { + static let schemeKey = "heatmapColorScheme" + static let customLowColorKey = "heatmapCustomLowColor" + static let customHighColorKey = "heatmapCustomHighColor" + static let defaultCustomLowHex = "#FFE082" + static let defaultCustomHighHex = "#C62828" + + private static let presetStops: [HeatmapColorScheme: (light: [NSColor], dark: [NSColor])] = [ + .warm: ( + light: [rgb(255, 196, 92), rgb(251, 140, 60), rgb(229, 57, 53)], + dark: [rgb(196, 132, 40), rgb(226, 110, 38), rgb(222, 64, 56)] + ), + .spectrum: ( + light: [rgb(100, 181, 246), rgb(255, 202, 40), rgb(229, 57, 53)], + dark: [rgb(30, 136, 229), rgb(253, 216, 53), rgb(239, 83, 80)] + ), + .green: ( + light: [rgb(129, 199, 132), rgb(67, 160, 71), rgb(27, 94, 32)], + dark: [rgb(46, 125, 50), rgb(67, 160, 71), rgb(129, 199, 132)] + ), + .teal: ( + light: [rgb(128, 222, 234), rgb(0, 172, 193), rgb(0, 96, 100)], + dark: [rgb(0, 96, 100), rgb(0, 151, 167), rgb(77, 208, 225)] + ), + .purple: ( + light: [rgb(206, 147, 216), rgb(142, 36, 170), rgb(74, 20, 140)], + dark: [rgb(106, 27, 154), rgb(156, 39, 176), rgb(206, 147, 216)] + ), + .rose: ( + light: [rgb(248, 187, 208), rgb(236, 64, 122), rgb(173, 20, 87)], + dark: [rgb(136, 14, 79), rgb(216, 27, 96), rgb(244, 143, 177)] + ) + ] + + // MARK: Settings + + static var scheme: HeatmapColorScheme { + get { + let rawValue = UserDefaults.standard.string(forKey: schemeKey) ?? HeatmapColorScheme.default.rawValue + return HeatmapColorScheme(rawValue: rawValue) ?? .default + } + set { + UserDefaults.standard.set(newValue.rawValue, forKey: schemeKey) + notifyChange() + } + } + + static var customLowColor: NSColor { + get { storedColor(forKey: customLowColorKey, fallbackHex: defaultCustomLowHex) } + set { + UserDefaults.standard.set(hexString(from: newValue), forKey: customLowColorKey) + notifyChange() + } + } + + static var customHighColor: NSColor { + get { storedColor(forKey: customHighColorKey, fallbackHex: defaultCustomHighHex) } + set { + UserDefaults.standard.set(hexString(from: newValue), forKey: customHighColorKey) + notifyChange() + } + } + + static func notifyChange() { + NotificationCenter.default.post(name: .heatmapColorSchemeDidChange, object: nil) + } + + // MARK: Colors + + /// 按键次数为 0 时的底色。需在目标 appearance 下调用(如 draw(_:) 内)。 + static func neutralColor(isDarkMode: Bool) -> NSColor { + let surfaceColor = isDarkMode ? NSColor.windowBackgroundColor : NSColor.controlBackgroundColor + return surfaceColor.blended(withFraction: isDarkMode ? 0.22 : 0.10, of: .separatorColor) + ?? (isDarkMode + ? NSColor(calibratedRed: 0.20, green: 0.22, blue: 0.25, alpha: 1) + : NSColor(calibratedRed: 0.93, green: 0.94, blue: 0.96, alpha: 1)) + } - return blendColor(from: lowColor, to: highColor, t: eased) + /// 按当前设置取色,intensity 为 0~1 的热度。 + static func color(intensity: CGFloat, isDarkMode: Bool) -> NSColor { + color(for: scheme, intensity: intensity, isDarkMode: isDarkMode) + } + + static func color(for scheme: HeatmapColorScheme, intensity: CGFloat, isDarkMode: Bool) -> NSColor { + let neutral = HeatmapPalette.neutralColor(isDarkMode: isDarkMode) + + switch scheme { + case .default: + // 默认:跟随系统强调色(保持原有配色不变) + let accentColor = NSColor.controlAccentColor + let lowColor = neutral.blended(withFraction: isDarkMode ? 0.34 : 0.24, of: accentColor) + ?? blend(from: neutral, to: accentColor, t: isDarkMode ? 0.34 : 0.24) + let highColor = accentColor.blended(withFraction: isDarkMode ? 0.18 : 0.10, of: isDarkMode ? .white : .black) + ?? blend(from: accentColor, to: isDarkMode ? .white : .black, t: isDarkMode ? 0.18 : 0.10) + return blend(from: lowColor, to: highColor, t: intensity) + case .custom: + let low = customLowColor + let high = customHighColor + return threeStopColor(neutral: neutral, + low: low, + mid: blend(from: low, to: high, t: 0.5), + high: high, + intensity: intensity, + isDarkMode: isDarkMode) + default: + guard let stops = presetStops[scheme] else { + return color(for: .default, intensity: intensity, isDarkMode: isDarkMode) + } + let colors = isDarkMode ? stops.dark : stops.light + return threeStopColor(neutral: neutral, + low: colors[0], + mid: colors[1], + high: colors[2], + intensity: intensity, + isDarkMode: isDarkMode) + } + } + + /// 设置页下拉项里的色条缩略图;在绘制时按当前 appearance 取色,深浅色切换后自动正确。 + static func thumbnailImage(for scheme: HeatmapColorScheme, size: NSSize = NSSize(width: 36, height: 10)) -> NSImage { + let image = NSImage(size: size, flipped: false) { rect in + let isDarkMode = NSAppearance.currentDrawing().bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let steps = 6 + var colors: [NSColor] = [] + var locations: [CGFloat] = [] + for index in 0...steps { + let t = CGFloat(index) / CGFloat(steps) + colors.append(HeatmapPalette.color(for: scheme, intensity: max(0.05, t), isDarkMode: isDarkMode)) + locations.append(t) + } + let path = NSBezierPath(roundedRect: rect, xRadius: 2.5, yRadius: 2.5) + locations.withUnsafeBufferPointer { buffer in + NSGradient(colors: colors, atLocations: buffer.baseAddress, colorSpace: .deviceRGB)?.draw(in: path, angle: 0) + } + return true + } + return image } - private func blendColor(from: NSColor, to: NSColor, t: CGFloat) -> NSColor { + static func blend(from: NSColor, to: NSColor, t: CGFloat) -> NSColor { let progress = max(0, min(1, t)) let fromRGB = from.usingColorSpace(.deviceRGB) ?? from let toRGB = to.usingColorSpace(.deviceRGB) ?? to @@ -1332,4 +1503,123 @@ private final class KeyboardHeatmapView: NSView { return NSColor(calibratedRed: red, green: green, blue: blue, alpha: alpha) } + + /// Selects the higher-contrast foreground for the actual keycap color. + static func legendColor(on background: NSColor) -> NSColor { + let rgb = background.usingColorSpace(.sRGB) ?? .black + func linear(_ component: CGFloat) -> CGFloat { + let value = max(0, min(1, component)) + return value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4) + } + let luminance = 0.2126 * linear(rgb.redComponent) + + 0.7152 * linear(rgb.greenComponent) + + 0.0722 * linear(rgb.blueComponent) + let blackContrast = (luminance + 0.05) / 0.05 + let whiteContrast = 1.05 / (luminance + 0.05) + return blackContrast >= whiteContrast ? .black : .white + } + + static func color(fromHex hex: String) -> NSColor? { + var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6, let rgbValue = UInt32(value, radix: 16) else { return nil } + return NSColor(srgbRed: CGFloat((rgbValue >> 16) & 0xFF) / 255.0, + green: CGFloat((rgbValue >> 8) & 0xFF) / 255.0, + blue: CGFloat(rgbValue & 0xFF) / 255.0, + alpha: 1) + } + + static func hexString(from color: NSColor) -> String { + let srgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) ?? NSColor.black + let red = Int((max(0, min(1, srgb.redComponent)) * 255).rounded()) + let green = Int((max(0, min(1, srgb.greenComponent)) * 255).rounded()) + let blue = Int((max(0, min(1, srgb.blueComponent)) * 255).rounded()) + return String(format: "#%02X%02X%02X", red, green, blue) + } + + private static func storedColor(forKey key: String, fallbackHex: String) -> NSColor { + if let stored = UserDefaults.standard.string(forKey: key), let parsed = HeatmapPalette.color(fromHex: stored) { + return parsed + } + return HeatmapPalette.color(fromHex: fallbackHex) ?? .black + } + + private static func threeStopColor(neutral: NSColor, + low lowStop: NSColor, + mid: NSColor, + high: NSColor, + intensity: CGFloat, + isDarkMode: Bool) -> NSColor { + // 低频键与底色混合,避免只按过一两次的键过于抢眼 + let low = blend(from: neutral, to: lowStop, t: isDarkMode ? 0.45 : 0.40) + return intensity < 0.5 + ? blend(from: low, to: mid, t: intensity / 0.5) + : blend(from: mid, to: high, t: (intensity - 0.5) / 0.5) + } + + private static func rgb(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> NSColor { + NSColor(srgbRed: red / 255.0, green: green / 255.0, blue: blue / 255.0, alpha: 1) + } +} + +/// 设置页的色阶预览条:第一格为无按键底色,其余从低到高热度。 +final class HeatmapPalettePreviewView: NSView { + private let cellCount = 8 + private var schemeObserver: NSObjectProtocol? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + schemeObserver = NotificationCenter.default.addObserver( + forName: .heatmapColorSchemeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + self?.needsDisplay = true + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let observer = schemeObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: 18) + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + + let isDarkMode = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let gap: CGFloat = 4 + let cellWidth = (bounds.width - gap * CGFloat(cellCount - 1)) / CGFloat(cellCount) + guard cellWidth > 0 else { return } + + let borderColor = isDarkMode ? NSColor.white.withAlphaComponent(0.14) : NSColor.black.withAlphaComponent(0.09) + for index in 0.. NSStackView { + heatmapColorSchemePopUp = NSPopUpButton() + heatmapColorSchemePopUp.controlSize = .small + heatmapColorSchemePopUp.font = NSFont.systemFont(ofSize: 12) + for scheme in HeatmapColorScheme.allCases { + heatmapColorSchemePopUp.addItem(withTitle: scheme.localizedTitle) + heatmapColorSchemePopUp.lastItem?.representedObject = scheme.rawValue + heatmapColorSchemePopUp.lastItem?.image = HeatmapPalette.thumbnailImage(for: scheme) + } + heatmapColorSchemePopUp.target = self + heatmapColorSchemePopUp.action = #selector(heatmapColorSchemeChanged) + + let schemeRow = makeLabeledRow(title: NSLocalizedString("settings.heatmapColorScheme", comment: ""), + control: heatmapColorSchemePopUp) + + heatmapCustomLowColorWell = makeHeatmapColorWell() + heatmapCustomHighColorWell = makeHeatmapColorWell() + let customColorsStack = makeOptionSeparatedStack([ + makeLabeledRow(title: NSLocalizedString("settings.heatmapCustomLowColor", comment: ""), + control: heatmapCustomLowColorWell), + makeLabeledRow(title: NSLocalizedString("settings.heatmapCustomHighColor", comment: ""), + control: heatmapCustomHighColorWell) + ], spacing: 6) + heatmapCustomColorsContainer = makeVerticalStack(spacing: 6) + addArrangedSubviewFillingWidth(makeOptionDividerContainer(), to: heatmapCustomColorsContainer) + addArrangedSubviewFillingWidth(makeIndentedContainer(customColorsStack, indent: 22), to: heatmapCustomColorsContainer) + + let lessLabel = NSTextField(labelWithString: NSLocalizedString("settings.heatmapPreview.less", comment: "")) + let moreLabel = NSTextField(labelWithString: NSLocalizedString("settings.heatmapPreview.more", comment: "")) + for label in [lessLabel, moreLabel] { + label.font = NSFont.systemFont(ofSize: 11) + label.textColor = .secondaryLabelColor + label.setContentHuggingPriority(.required, for: .horizontal) + } + heatmapPalettePreviewView = HeatmapPalettePreviewView(frame: .zero) + heatmapPalettePreviewView.translatesAutoresizingMaskIntoConstraints = false + heatmapPalettePreviewView.heightAnchor.constraint(equalToConstant: 18).isActive = true + heatmapPalettePreviewView.setContentHuggingPriority(.defaultLow, for: .horizontal) + let previewRow = NSStackView(views: [lessLabel, heatmapPalettePreviewView, moreLabel]) + previewRow.orientation = .horizontal + previewRow.alignment = .centerY + previewRow.spacing = 8 + + let stack = makeVerticalStack(spacing: 8) + addArrangedSubviewFillingWidth(schemeRow, to: stack) + addArrangedSubviewFillingWidth(heatmapCustomColorsContainer, to: stack) + addArrangedSubviewFillingWidth(makeOptionDividerContainer(), to: stack) + addArrangedSubviewFillingWidth(previewRow, to: stack) + return stack + } + + private func makeHeatmapColorWell() -> NSColorWell { + let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 44, height: 22)) + colorWell.target = self + colorWell.action = #selector(heatmapCustomColorChanged(_:)) + colorWell.translatesAutoresizingMaskIntoConstraints = false + colorWell.widthAnchor.constraint(equalToConstant: 44).isActive = true + colorWell.heightAnchor.constraint(equalToConstant: 22).isActive = true + return colorWell + } + private func makeSection(titleKey: String, content: NSView) -> NSView { let titleLabel = makeSectionTitleLabel(NSLocalizedString(titleKey, comment: "")) @@ -719,9 +788,20 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate { showThresholdsButton.state = notificationsEnabled ? .on : .off notificationThresholdContainer.isHidden = !notificationsEnabled updateThresholdUI() + updateHeatmapColorSchemeState() updateSyncState() } + private func updateHeatmapColorSchemeState() { + let scheme = HeatmapPalette.scheme + if let item = heatmapColorSchemePopUp.itemArray.first(where: { ($0.representedObject as? String) == scheme.rawValue }) { + heatmapColorSchemePopUp.select(item) + } + heatmapCustomLowColorWell.color = HeatmapPalette.customLowColor + heatmapCustomHighColorWell.color = HeatmapPalette.customHighColor + heatmapCustomColorsContainer.isHidden = scheme != .custom + } + private func updateSyncState() { let coordinator = SyncCoordinator.shared let state = coordinator.state @@ -1082,6 +1162,31 @@ final class SettingsViewController: NSViewController, NSTextFieldDelegate { StatsManager.shared.menuBarUpdateHandler?() } + @objc private func heatmapColorSchemeChanged() { + guard let rawValue = heatmapColorSchemePopUp.selectedItem?.representedObject as? String, + let scheme = HeatmapColorScheme(rawValue: rawValue), + scheme != HeatmapPalette.scheme else { return } + HeatmapPalette.scheme = scheme + heatmapCustomColorsContainer.isHidden = scheme != .custom + if scheme != .custom { + heatmapCustomLowColorWell.deactivate() + heatmapCustomHighColorWell.deactivate() + } + AppDelegate.trackClick("setting_heatmap_color_scheme", properties: ["scheme": rawValue]) + } + + @objc private func heatmapCustomColorChanged(_ sender: NSColorWell) { + if sender === heatmapCustomLowColorWell { + HeatmapPalette.customLowColor = sender.color + } else { + HeatmapPalette.customHighColor = sender.color + } + // 自定义项的色条缩略图需按新颜色重建 + if let item = heatmapColorSchemePopUp.itemArray.first(where: { ($0.representedObject as? String) == HeatmapColorScheme.custom.rawValue }) { + item.image = HeatmapPalette.thumbnailImage(for: .custom) + } + } + @objc private func toggleLaunchAtLogin() { let shouldEnable = launchAtLoginButton.state == .on do { diff --git a/KeyStats/en.lproj/Localizable.strings b/KeyStats/en.lproj/Localizable.strings index 729676f..0a86356 100644 --- a/KeyStats/en.lproj/Localizable.strings +++ b/KeyStats/en.lproj/Localizable.strings @@ -381,3 +381,19 @@ "hourly.summary.metric" = "Synced total: %ld · This Mac: %ld · Busiest hour: %@"; "hourly.detail.metric" = "%@ · Synced total: %@ · This Mac: %@"; "hourly.import.differentTimeZones" = "These hourly records use different time zones and cannot be merged accurately. Use Replace to restore the backup instead."; + +/* Heatmap color scheme */ +"settings.section.heatmap" = "Heatmap Colors"; +"settings.heatmapColorScheme" = "Key Colors"; +"settings.heatmapColorScheme.default" = "Default (Blue)"; +"settings.heatmapColorScheme.warm" = "Warm (Orange-Red)"; +"settings.heatmapColorScheme.spectrum" = "Spectrum (Blue-Yellow-Red)"; +"settings.heatmapColorScheme.green" = "Green"; +"settings.heatmapColorScheme.teal" = "Teal"; +"settings.heatmapColorScheme.purple" = "Purple"; +"settings.heatmapColorScheme.rose" = "Rose"; +"settings.heatmapColorScheme.custom" = "Custom"; +"settings.heatmapCustomLowColor" = "Start Color"; +"settings.heatmapCustomHighColor" = "Peak Color"; +"settings.heatmapPreview.less" = "Less"; +"settings.heatmapPreview.more" = "More"; diff --git a/KeyStats/zh-Hans.lproj/Localizable.strings b/KeyStats/zh-Hans.lproj/Localizable.strings index 66eb40a..36a43dc 100644 --- a/KeyStats/zh-Hans.lproj/Localizable.strings +++ b/KeyStats/zh-Hans.lproj/Localizable.strings @@ -381,3 +381,19 @@ "hourly.summary.metric" = "总数:%ld · 本设备:%ld · 最活跃时段:%@"; "hourly.detail.metric" = "%@ · 总数:%@ · 本设备:%@"; "hourly.import.differentTimeZones" = "小时记录的时区不同,无法准确合并。请使用覆盖导入恢复备份。"; + +/* Heatmap color scheme */ +"settings.section.heatmap" = "热力图配色"; +"settings.heatmapColorScheme" = "键盘热力图的按键颜色"; +"settings.heatmapColorScheme.default" = "默认(蓝色)"; +"settings.heatmapColorScheme.warm" = "暖色(橙红)"; +"settings.heatmapColorScheme.spectrum" = "光谱(蓝黄红)"; +"settings.heatmapColorScheme.green" = "绿色"; +"settings.heatmapColorScheme.teal" = "青色"; +"settings.heatmapColorScheme.purple" = "紫色"; +"settings.heatmapColorScheme.rose" = "玫红"; +"settings.heatmapColorScheme.custom" = "自定义"; +"settings.heatmapCustomLowColor" = "起始色"; +"settings.heatmapCustomHighColor" = "最高色"; +"settings.heatmapPreview.less" = "少"; +"settings.heatmapPreview.more" = "多"; diff --git a/KeyStats/zh-Hant.lproj/Localizable.strings b/KeyStats/zh-Hant.lproj/Localizable.strings index 610d2d6..116f79f 100644 --- a/KeyStats/zh-Hant.lproj/Localizable.strings +++ b/KeyStats/zh-Hant.lproj/Localizable.strings @@ -381,3 +381,19 @@ "hourly.summary.metric" = "總數:%ld · 本裝置:%ld · 最活躍時段:%@"; "hourly.detail.metric" = "%@ · 總數:%@ · 本裝置:%@"; "hourly.import.differentTimeZones" = "小時記錄的時區不同,無法準確合併。請使用覆蓋匯入還原備份。"; + +/* Heatmap color scheme */ +"settings.section.heatmap" = "熱力圖配色"; +"settings.heatmapColorScheme" = "鍵盤熱力圖的按鍵顏色"; +"settings.heatmapColorScheme.default" = "預設(藍色)"; +"settings.heatmapColorScheme.warm" = "暖色(橙紅)"; +"settings.heatmapColorScheme.spectrum" = "光譜(藍黃紅)"; +"settings.heatmapColorScheme.green" = "綠色"; +"settings.heatmapColorScheme.teal" = "青色"; +"settings.heatmapColorScheme.purple" = "紫色"; +"settings.heatmapColorScheme.rose" = "玫紅"; +"settings.heatmapColorScheme.custom" = "自訂"; +"settings.heatmapCustomLowColor" = "起始色"; +"settings.heatmapCustomHighColor" = "最高色"; +"settings.heatmapPreview.less" = "少"; +"settings.heatmapPreview.more" = "多";