From c5c6e3ff32639ed35b7b8affb753e3d46b36afb5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Thu, 22 Oct 2026 15:44:55 +0800
Subject: [PATCH 1/4] feat(heatmap): add heatmap color schemes on Windows
Add a heatmap color setting with 7 presets (default blue, warm, spectrum,
green, teal, purple, rose) plus a custom low/high color picked from the
system color dialog. The settings card shows a live palette preview and
open heatmap windows repaint immediately. Default palette is unchanged.
---
.../KeyStats/Helpers/HeatmapPalette.cs | 177 ++++++++++++++++++
.../KeyStats/Models/AppSettings.cs | 17 ++
.../KeyStats/Properties/Strings.cs | 14 ++
.../KeyStats/Properties/Strings.resx | 14 ++
.../KeyStats/Properties/Strings.zh-Hans.resx | 14 ++
.../KeyStats/Properties/Strings.zh-Hant.resx | 14 ++
.../Controls/HeatmapPalettePreviewControl.cs | 76 ++++++++
.../Views/Controls/KeyboardHeatmapControl.cs | 24 +--
.../KeyStats/Views/SettingsWindow.xaml | 74 ++++++++
.../KeyStats/Views/SettingsWindow.xaml.cs | 146 ++++++++++++++-
10 files changed, 549 insertions(+), 21 deletions(-)
create mode 100644 KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
create mode 100644 KeyStats.Windows/KeyStats/Views/Controls/HeatmapPalettePreviewControl.cs
diff --git a/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
new file mode 100644
index 00000000..42a090e4
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
@@ -0,0 +1,177 @@
+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 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 d58f5e2f..4319165e 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 9de5a5aa..b359298b 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 b4f5e98e..8a008a54 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 0f7a9693..42b4f4e3 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 d041a3c3..655f800d 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 00000000..a4424e2c
--- /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 62a5c0f0..91079cc6 100644
--- a/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
+++ b/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
@@ -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 3c28d7cc..ee4ac008 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 c2f42452..ed2a64db 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)
From 9d30c91a9b248bd448ba2e23e52d6c4244144fda Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E6=AF=AB?= <2329095893@qq.com>
Date: Thu, 22 Oct 2026 15:44:55 +0800
Subject: [PATCH 2/4] feat(heatmap): add heatmap color schemes on macOS
Mirror the Windows heatmap color setting: same presets and gradient math,
custom low/high colors via NSColorWell, palette preview in settings, and
live repaint of open heatmap views. Default palette is unchanged.
---
KeyStats/KeyboardHeatmapViewController.swift | 302 ++++++++++++++++++-
KeyStats/SettingsViewController.swift | 105 +++++++
KeyStats/en.lproj/Localizable.strings | 16 +
KeyStats/zh-Hans.lproj/Localizable.strings | 16 +
KeyStats/zh-Hant.lproj/Localizable.strings | 16 +
5 files changed, 441 insertions(+), 14 deletions(-)
diff --git a/KeyStats/KeyboardHeatmapViewController.swift b/KeyStats/KeyboardHeatmapViewController.swift
index 689d9c18..1a7ad336 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
@@ -1296,14 +1316,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 +1325,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()
+ }
+ }
- return blendColor(from: lowColor, to: highColor, t: eased)
+ static var customHighColor: NSColor {
+ get { storedColor(forKey: customHighColorKey, fallbackHex: defaultCustomHighHex) }
+ set {
+ UserDefaults.standard.set(hexString(from: newValue), forKey: customHighColorKey)
+ notifyChange()
+ }
}
- private func blendColor(from: NSColor, to: NSColor, t: CGFloat) -> NSColor {
+ 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))
+ }
+
+ /// 按当前设置取色,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
+ }
+
+ 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 +1502,108 @@ private final class KeyboardHeatmapView: NSView {
return NSColor(calibratedRed: red, green: green, blue: blue, alpha: alpha)
}
+
+ 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 729676fa..0a86356a 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 66eb40a7..36a43dc9 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 610d2d6a..116f79f4 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" = "多";
From 03f5b91717bcb5ea32d588bcaabc3cdae322aa20 Mon Sep 17 00:00:00 2001
From: pipizhu
Date: Thu, 22 Oct 2026 17:32:58 +0800
Subject: [PATCH 3/4] fix(heatmap): keep key legends readable across color
schemes
---
.../HeatmapPaletteTests.cs | 61 +++++++++++++++++++
.../KeyStats/Helpers/HeatmapPalette.cs | 17 ++++++
.../Views/Controls/KeyboardHeatmapControl.cs | 6 +-
KeyStats/KeyboardHeatmapViewController.swift | 20 +++++-
4 files changed, 99 insertions(+), 5 deletions(-)
create mode 100644 KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs b/KeyStats.Windows/KeyStats.Sync.Tests/HeatmapPaletteTests.cs
new file mode 100644
index 00000000..892b7dfd
--- /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/Helpers/HeatmapPalette.cs b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
index 42a090e4..d6626648 100644
--- a/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
+++ b/KeyStats.Windows/KeyStats/Helpers/HeatmapPalette.cs
@@ -135,6 +135,23 @@ public static Color ParseColor(string? hex, Color 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);
diff --git a/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs b/KeyStats.Windows/KeyStats/Views/Controls/KeyboardHeatmapControl.cs
index 91079cc6..4d5c54f2 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);
}
}
diff --git a/KeyStats/KeyboardHeatmapViewController.swift b/KeyStats/KeyboardHeatmapViewController.swift
index 1a7ad336..584a3599 100644
--- a/KeyStats/KeyboardHeatmapViewController.swift
+++ b/KeyStats/KeyboardHeatmapViewController.swift
@@ -1030,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()
@@ -1132,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)
}
@@ -1503,6 +1504,21 @@ enum HeatmapPalette {
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("#") {
From 092177359fcf91bc74fc5bb2557c4b2258985c1f Mon Sep 17 00:00:00 2001
From: pipizhu
Date: Thu, 22 Oct 2026 17:36:00 +0800
Subject: [PATCH 4/4] test(heatmap): enable WPF references for palette
regression tests
---
KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj | 1 +
1 file changed, 1 insertion(+)
diff --git a/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj b/KeyStats.Windows/KeyStats.Sync.Tests/KeyStats.Sync.Tests.csproj
index 99fc3e67..fd0320b6 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