diff --git a/KeyStats.Windows/KeyStats/App.xaml b/KeyStats.Windows/KeyStats/App.xaml
index c6927f2..70e2b9a 100644
--- a/KeyStats.Windows/KeyStats/App.xaml
+++ b/KeyStats.Windows/KeyStats/App.xaml
@@ -22,6 +22,7 @@
#FAFAFA
#B8FAFAFA
#D9F2F2F2
+ #A8FAFAFA
#20000000
#B8FAFAFA
#E5E5E5
@@ -40,6 +41,7 @@
+
diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs
index d456dd6..364e7f5 100644
--- a/KeyStats.Windows/KeyStats/App.xaml.cs
+++ b/KeyStats.Windows/KeyStats/App.xaml.cs
@@ -11,6 +11,7 @@
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
+using System.Windows.Threading;
using KeyStats.Helpers;
using KeyStats.Services;
using KeyStats.ViewModels;
@@ -36,11 +37,15 @@ public partial class App : System.Windows.Application
private KeyboardHeatmapWindow? _keyboardHeatmapWindow;
private KeyHistoryWindow? _keyHistoryWindow;
private SyncSettingsWindow? _syncSettingsWindow;
+ private FloatingStatsWindow? _floatingStatsWindow;
+ private MenuItem? _floatingStatsMenuItem;
+ private DispatcherTimer? _floatingStatsVisibilityTimer;
private System.Threading.Mutex? _singleInstanceMutex;
private string? _appVersion;
private IPostHogAnalytics? _postHogClient;
private SyncCoordinator? _syncCoordinator;
private long _lastResumeRecoveryTicks;
+ private bool _isFloatingStatsHiddenForFullscreen;
protected override void OnStartup(StartupEventArgs e)
{
@@ -132,6 +137,11 @@ protected override void OnStartup(StartupEventArgs e)
});
RecreateTrayIntegration();
+ if (statsManager.Settings.FloatingStatsEnabled)
+ {
+ ShowFloatingStatsWindow();
+ }
+
Console.WriteLine("Tray icon created successfully!");
Console.WriteLine("App is running. Look for the icon in the system tray.");
}
@@ -157,6 +167,23 @@ private System.Windows.Controls.ContextMenu CreateContextMenu()
};
menu.Items.Add(openMainWindowItem);
+ _floatingStatsMenuItem = new System.Windows.Controls.MenuItem
+ {
+ Header = KeyStats.Properties.Strings.Tray_ShowFloatingStats,
+ IsCheckable = true,
+ IsChecked = StatsManager.Instance.Settings.FloatingStatsEnabled
+ };
+ _floatingStatsMenuItem.Click += (s, e) =>
+ {
+ var menuItem = (System.Windows.Controls.MenuItem)s!;
+ TrackClick("context_menu_floating_stats", new Dictionary
+ {
+ ["enabled"] = menuItem.IsChecked
+ });
+ SetFloatingStatsVisible(menuItem.IsChecked);
+ };
+ menu.Items.Add(_floatingStatsMenuItem);
+
var settingsItem = new System.Windows.Controls.MenuItem { Header = KeyStats.Properties.Strings.Tray_Settings };
settingsItem.Click += (s, e) =>
{
@@ -271,6 +298,134 @@ public void ShowStatsPanel()
_trayIconViewModel?.ShowStatsCommand.Execute(null);
}
+ public void ShowFloatingStatsWindow()
+ {
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(new Action(ShowFloatingStatsWindow));
+ return;
+ }
+
+ StartFloatingStatsVisibilityMonitor();
+ if (FullscreenWindowDetector.IsForegroundWindowFullscreen())
+ {
+ _isFloatingStatsHiddenForFullscreen = true;
+ _floatingStatsWindow?.Hide();
+ return;
+ }
+
+ _isFloatingStatsHiddenForFullscreen = false;
+
+ if (_floatingStatsWindow != null)
+ {
+ _floatingStatsWindow.ShowWindow();
+ return;
+ }
+
+ _floatingStatsWindow = new FloatingStatsWindow();
+ _floatingStatsWindow.Closed += (_, _) => _floatingStatsWindow = null;
+ _floatingStatsWindow.ShowWindow();
+ }
+
+ public void SetFloatingStatsVisible(bool isVisible)
+ {
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(new Action(() => SetFloatingStatsVisible(isVisible)));
+ return;
+ }
+
+ var settings = StatsManager.Instance.Settings;
+ if (settings.FloatingStatsEnabled != isVisible)
+ {
+ settings.FloatingStatsEnabled = isVisible;
+ StatsManager.Instance.SaveSettings();
+ }
+
+ if (_floatingStatsMenuItem != null)
+ {
+ _floatingStatsMenuItem.IsChecked = isVisible;
+ }
+
+ if (isVisible)
+ {
+ ShowFloatingStatsWindow();
+ return;
+ }
+
+ StopFloatingStatsVisibilityMonitor();
+ _isFloatingStatsHiddenForFullscreen = false;
+ _floatingStatsWindow?.Close();
+ _floatingStatsWindow = null;
+ }
+
+ private void StartFloatingStatsVisibilityMonitor()
+ {
+ if (_floatingStatsVisibilityTimer == null)
+ {
+ _floatingStatsVisibilityTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(500)
+ };
+ _floatingStatsVisibilityTimer.Tick += OnFloatingStatsVisibilityTimerTick;
+ }
+
+ _floatingStatsVisibilityTimer.Start();
+ }
+
+ private void StopFloatingStatsVisibilityMonitor()
+ {
+ if (_floatingStatsVisibilityTimer == null)
+ {
+ return;
+ }
+
+ _floatingStatsVisibilityTimer.Stop();
+ _floatingStatsVisibilityTimer.Tick -= OnFloatingStatsVisibilityTimerTick;
+ _floatingStatsVisibilityTimer = null;
+ }
+
+ private void OnFloatingStatsVisibilityTimerTick(object? sender, EventArgs e)
+ {
+ if (!StatsManager.Instance.Settings.FloatingStatsEnabled)
+ {
+ StopFloatingStatsVisibilityMonitor();
+ return;
+ }
+
+ var shouldHideForFullscreen = FullscreenWindowDetector.IsForegroundWindowFullscreen();
+ if (shouldHideForFullscreen)
+ {
+ if (_isFloatingStatsHiddenForFullscreen)
+ {
+ return;
+ }
+
+ _isFloatingStatsHiddenForFullscreen = true;
+ _floatingStatsWindow?.Hide();
+ return;
+ }
+
+ if (!_isFloatingStatsHiddenForFullscreen)
+ {
+ return;
+ }
+
+ _isFloatingStatsHiddenForFullscreen = false;
+ ShowFloatingStatsWindow();
+ }
+
+ public void ApplyFloatingStatsBehaviorSettings()
+ {
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(new Action(ApplyFloatingStatsBehaviorSettings));
+ return;
+ }
+
+ _floatingStatsWindow?.ApplyBehaviorSettings();
+ }
+
public void ShowMainWindow()
{
_trayIconViewModel?.ShowMainWindow();
@@ -470,6 +625,7 @@ private static string MakeExportFileName()
protected override void OnExit(ExitEventArgs e)
{
+ StopFloatingStatsVisibilityMonitor();
UnregisterSystemEventHandlers();
if (_trayIconViewModel != null)
{
@@ -487,6 +643,8 @@ protected override void OnExit(ExitEventArgs e)
_trayIcon.Dispose();
_trayIcon = null;
}
+ _floatingStatsWindow?.Close();
+ _floatingStatsWindow = null;
InputMonitorService.Instance.StopMonitoring();
_syncCoordinator?.Dispose();
_syncCoordinator = null;
@@ -1024,6 +1182,7 @@ private void RecreateTrayIntegration()
Visible = true
};
_trayIcon.MouseClick += OnTrayIconMouseClick;
+
}
private void OnTrayIconMouseClick(object? sender, Forms.MouseEventArgs e)
diff --git a/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs b/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs
new file mode 100644
index 0000000..c143c3c
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs
@@ -0,0 +1,81 @@
+using System;
+using System.Runtime.InteropServices;
+
+namespace KeyStats.Helpers;
+
+public static class FullscreenWindowDetector
+{
+ private const int BoundsTolerance = 2;
+
+ ///
+ /// Returns whether the foreground app covers the bounds of its nearest monitor.
+ ///
+ public static bool IsForegroundWindowFullscreen()
+ {
+ var windowHandle = NativeInterop.GetForegroundWindow();
+ if (windowHandle == IntPtr.Zero ||
+ windowHandle == NativeInterop.GetDesktopWindow() ||
+ windowHandle == NativeInterop.GetShellWindow() ||
+ !NativeInterop.IsWindowVisible(windowHandle) ||
+ NativeInterop.IsIconic(windowHandle) ||
+ !NativeInterop.GetWindowRect(windowHandle, out var windowBounds))
+ {
+ return false;
+ }
+
+ var monitorHandle = NativeInterop.MonitorFromWindow(
+ windowHandle,
+ NativeInterop.MONITOR_DEFAULTTONEAREST);
+ if (monitorHandle == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ var monitorInfo = new NativeInterop.MONITORINFO
+ {
+ cbSize = (uint)Marshal.SizeOf(typeof(NativeInterop.MONITORINFO))
+ };
+ if (!NativeInterop.GetMonitorInfo(monitorHandle, ref monitorInfo))
+ {
+ return false;
+ }
+
+ if (!CoversMonitor(windowBounds, monitorInfo.rcMonitor))
+ {
+ return false;
+ }
+
+ // Some video players keep WS_MAXIMIZE while Windows still reports fullscreen mode.
+ return !NativeInterop.IsZoomed(windowHandle) ||
+ IsSystemInFullscreenMode();
+ }
+
+ private static bool IsSystemInFullscreenMode()
+ {
+ var result = NativeInterop.SHQueryUserNotificationState(out var state);
+ if (result != 0)
+ {
+ return false;
+ }
+
+ return state == NativeInterop.UserNotificationState.Busy ||
+ state == NativeInterop.UserNotificationState.RunningDirect3DFullscreen ||
+ state == NativeInterop.UserNotificationState.PresentationMode;
+ }
+
+ internal static bool CoversMonitor(NativeInterop.RECT windowBounds, NativeInterop.RECT monitorBounds)
+ {
+ if (windowBounds.Right <= windowBounds.Left ||
+ windowBounds.Bottom <= windowBounds.Top ||
+ monitorBounds.Right <= monitorBounds.Left ||
+ monitorBounds.Bottom <= monitorBounds.Top)
+ {
+ return false;
+ }
+
+ return windowBounds.Left <= monitorBounds.Left + BoundsTolerance &&
+ windowBounds.Top <= monitorBounds.Top + BoundsTolerance &&
+ windowBounds.Right >= monitorBounds.Right - BoundsTolerance &&
+ windowBounds.Bottom >= monitorBounds.Bottom - BoundsTolerance;
+ }
+}
diff --git a/KeyStats.Windows/KeyStats/Helpers/MonitorGeometryHelper.cs b/KeyStats.Windows/KeyStats/Helpers/MonitorGeometryHelper.cs
new file mode 100644
index 0000000..a714edf
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/Helpers/MonitorGeometryHelper.cs
@@ -0,0 +1,37 @@
+using System.Windows;
+using System.Windows.Media;
+using Forms = System.Windows.Forms;
+
+namespace KeyStats.Helpers;
+
+public static class MonitorGeometryHelper
+{
+ ///
+ /// Converts a monitor work area from device pixels to WPF device-independent units.
+ ///
+ public static Rect GetWorkingAreaInDips(Forms.Screen screen, Matrix fallbackTransform)
+ {
+ var bounds = screen.Bounds;
+ var center = new NativeInterop.POINT
+ {
+ x = bounds.Left + bounds.Width / 2,
+ y = bounds.Top + bounds.Height / 2
+ };
+ var monitor = NativeInterop.MonitorFromPoint(center, NativeInterop.MONITOR_DEFAULTTONEAREST);
+ if (NativeInterop.TryGetMonitorScaleFactor(monitor, out var scaleFactor))
+ {
+ var workingArea = screen.WorkingArea;
+ return new Rect(
+ workingArea.Left / scaleFactor,
+ workingArea.Top / scaleFactor,
+ workingArea.Width / scaleFactor,
+ workingArea.Height / scaleFactor);
+ }
+
+ var fallbackTopLeft = fallbackTransform.Transform(
+ new Point(screen.WorkingArea.Left, screen.WorkingArea.Top));
+ var fallbackBottomRight = fallbackTransform.Transform(
+ new Point(screen.WorkingArea.Right, screen.WorkingArea.Bottom));
+ return new Rect(fallbackTopLeft, fallbackBottomRight);
+ }
+}
diff --git a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs
index 1f28fe7..71476a8 100644
--- a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs
+++ b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs
@@ -69,6 +69,15 @@ public struct RECT
public int Bottom;
}
+ [StructLayout(LayoutKind.Sequential)]
+ public struct MONITORINFO
+ {
+ public uint cbSize;
+ public RECT rcMonitor;
+ public RECT rcWork;
+ public uint dwFlags;
+ }
+
[StructLayout(LayoutKind.Sequential)]
public struct MARGINS
{
@@ -113,6 +122,20 @@ public struct MARGINS
[DllImport("shell32.dll", SetLastError = true)]
public static extern int Shell_NotifyIconGetRect(ref NOTIFYICONIDENTIFIER identifier, out RECT iconLocation);
+ public enum UserNotificationState
+ {
+ NotPresent = 1,
+ Busy = 2,
+ RunningDirect3DFullscreen = 3,
+ PresentationMode = 4,
+ AcceptsNotifications = 5,
+ QuietTime = 6,
+ App = 7
+ }
+
+ [DllImport("shell32.dll")]
+ public static extern int SHQueryUserNotificationState(out UserNotificationState state);
+
[StructLayout(LayoutKind.Sequential)]
public struct NOTIFYICONIDENTIFIER
{
@@ -144,6 +167,75 @@ public static short LoWord(int dword)
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
+ [DllImport("user32.dll")]
+ public static extern IntPtr GetDesktopWindow();
+
+ [DllImport("user32.dll")]
+ public static extern IntPtr GetShellWindow();
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool IsWindowVisible(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool IsIconic(IntPtr hWnd);
+
+ [DllImport("user32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool IsZoomed(IntPtr hWnd);
+
+ [DllImport("user32.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
+
+ public const uint MONITOR_DEFAULTTONEAREST = 0x00000002;
+
+ [DllImport("user32.dll")]
+ public static extern IntPtr MonitorFromWindow(IntPtr hWnd, uint dwFlags);
+
+ [DllImport("user32.dll")]
+ public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
+
+ [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi);
+
+ [DllImport("Shcore.dll")]
+ private static extern int GetScaleFactorForMonitor(IntPtr hMon, out int pScale);
+
+ ///
+ /// Gets the user-selected scale factor for a specific monitor when the API is available.
+ ///
+ public static bool TryGetMonitorScaleFactor(IntPtr hMonitor, out double scaleFactor)
+ {
+ scaleFactor = 1;
+ if (hMonitor == IntPtr.Zero)
+ {
+ return false;
+ }
+
+ try
+ {
+ var result = GetScaleFactorForMonitor(hMonitor, out var scalePercentage);
+ if (result != 0 || scalePercentage <= 0)
+ {
+ return false;
+ }
+
+ scaleFactor = scalePercentage / 100.0;
+ return true;
+ }
+ catch (DllNotFoundException)
+ {
+ return false;
+ }
+ catch (EntryPointNotFoundException)
+ {
+ return false;
+ }
+ }
+
[DllImport("user32.dll", SetLastError = true)]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
diff --git a/KeyStats.Windows/KeyStats/Helpers/ThemeManager.cs b/KeyStats.Windows/KeyStats/Helpers/ThemeManager.cs
index 8ae8e8c..2c034db 100644
--- a/KeyStats.Windows/KeyStats/Helpers/ThemeManager.cs
+++ b/KeyStats.Windows/KeyStats/Helpers/ThemeManager.cs
@@ -87,6 +87,7 @@ private static void ApplyLightTheme(ResourceDictionary res)
SetColor(res, "SurfaceColor", "#FAFAFA");
SetColor(res, "WindowSurfaceColor", "#B8FAFAFA");
SetColor(res, "CardColor", "#D9F2F2F2");
+ SetColor(res, "FloatingStatsSurfaceColor", "#A8FAFAFA");
SetColor(res, "TrayPopupBorderColor", "#20000000");
SetColor(res, "TrayBackdropTintColor", "#B8FAFAFA");
SetColor(res, "DividerColor", "#E5E5E5");
@@ -105,6 +106,7 @@ private static void ApplyLightTheme(ResourceDictionary res)
SetBrush(res, "SurfaceBrush", "#FAFAFA");
SetBrush(res, "WindowSurfaceBrush", "#B8FAFAFA");
SetBrush(res, "CardBrush", "#D9F2F2F2");
+ SetBrush(res, "FloatingStatsSurfaceBrush", "#A8FAFAFA");
SetBrush(res, "TrayPopupBorderBrush", "#20000000");
SetBrush(res, "TrayBackdropTintBrush", "#B8FAFAFA");
SetBrush(res, "DividerBrush", "#E5E5E5");
@@ -135,6 +137,7 @@ private static void ApplyDarkTheme(ResourceDictionary res)
SetColor(res, "SurfaceColor", "#202020");
SetColor(res, "WindowSurfaceColor", "#C8141414");
SetColor(res, "CardColor", "#CC1A1A1A");
+ SetColor(res, "FloatingStatsSurfaceColor", "#A8141414");
SetColor(res, "TrayPopupBorderColor", "#33FFFFFF");
SetColor(res, "TrayBackdropTintColor", "#A8202020");
SetColor(res, "DividerColor", "#3D3D3D");
@@ -153,6 +156,7 @@ private static void ApplyDarkTheme(ResourceDictionary res)
SetBrush(res, "SurfaceBrush", "#202020");
SetBrush(res, "WindowSurfaceBrush", "#C8141414");
SetBrush(res, "CardBrush", "#CC1A1A1A");
+ SetBrush(res, "FloatingStatsSurfaceBrush", "#A8141414");
SetBrush(res, "TrayPopupBorderBrush", "#33FFFFFF");
SetBrush(res, "TrayBackdropTintBrush", "#A8202020");
SetBrush(res, "DividerBrush", "#3D3D3D");
diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs
index 37d45f6..d58f5e2 100644
--- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs
+++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs
@@ -6,6 +6,13 @@ namespace KeyStats.Models;
public class AppSettings
{
public const double DefaultMouseMetersPerPixel = 0.00005;
+ public const string FloatingStatsSingleRowLayoutMode = "singleRow";
+ public const string FloatingStatsDoubleRowLayoutMode = "doubleRow";
+ public const int FloatingStatsLayoutBaseFontSize = 11;
+ public const int DefaultFloatingStatsFontSize = 12;
+ public const int MinimumFloatingStatsFontSize = 9;
+ public const int MaximumFloatingStatsFontSize = 22;
+ private int _floatingStatsFontSize = DefaultFloatingStatsFontSize;
[JsonPropertyName("notificationsEnabled")]
public bool NotificationsEnabled { get; set; }
@@ -58,6 +65,42 @@ public class AppSettings
[JsonPropertyName("mainWindowHeight")]
public double? MainWindowHeight { get; set; }
+ [JsonPropertyName("floatingStatsEnabled")]
+ public bool FloatingStatsEnabled { get; set; }
+
+ [JsonPropertyName("floatingStatsPrimaryMetric")]
+ public string FloatingStatsPrimaryMetric { get; set; } = "keyPresses";
+
+ [JsonPropertyName("floatingStatsSecondaryMetric")]
+ public string FloatingStatsSecondaryMetric { get; set; } = "totalClicks";
+
+ [JsonPropertyName("floatingStatsLayoutMode")]
+ public string FloatingStatsLayoutMode { get; set; } = FloatingStatsDoubleRowLayoutMode;
+
+ [JsonPropertyName("floatingStatsFontSize")]
+ public int FloatingStatsFontSize
+ {
+ get => _floatingStatsFontSize;
+ set => _floatingStatsFontSize = Math.Max(
+ MinimumFloatingStatsFontSize,
+ Math.Min(MaximumFloatingStatsFontSize, value));
+ }
+
+ [JsonPropertyName("floatingStatsLeft")]
+ public double? FloatingStatsLeft { get; set; }
+
+ [JsonPropertyName("floatingStatsTop")]
+ public double? FloatingStatsTop { get; set; }
+
+ [JsonPropertyName("floatingStatsMonitorDeviceName")]
+ public string? FloatingStatsMonitorDeviceName { get; set; }
+
+ [JsonPropertyName("floatingStatsTopmost")]
+ public bool FloatingStatsTopmost { get; set; } = true;
+
+ [JsonPropertyName("floatingStatsPositionLocked")]
+ public bool FloatingStatsPositionLocked { get; set; }
+
[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 ef22dee..7a7355a 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.cs
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs
@@ -29,6 +29,7 @@ public static class Strings
public static string Notif_ClickThresholdReachedFormat => Get(nameof(Notif_ClickThresholdReachedFormat));
public static string Tray_OpenMainWindow => Get(nameof(Tray_OpenMainWindow));
+ public static string Tray_ShowFloatingStats => Get(nameof(Tray_ShowFloatingStats));
public static string Tray_Settings => Get(nameof(Tray_Settings));
public static string Tray_StartAtLogin => Get(nameof(Tray_StartAtLogin));
public static string Tray_KeyHistory => Get(nameof(Tray_KeyHistory));
@@ -72,6 +73,8 @@ public static class Strings
public static string Settings_Sync => Get(nameof(Settings_Sync));
public static string Settings_SyncDesc => Get(nameof(Settings_SyncDesc));
public static string Settings_SyncUnavailable => Get(nameof(Settings_SyncUnavailable));
+ public static string Settings_FloatingStats => Get(nameof(Settings_FloatingStats));
+ public static string Settings_FloatingStatsDesc => Get(nameof(Settings_FloatingStatsDesc));
public static string Sync_WindowTitle => Get(nameof(Sync_WindowTitle));
public static string Sync_HeaderTitle => Get(nameof(Sync_HeaderTitle));
@@ -164,6 +167,18 @@ public static class Strings
public static string Metric_Scroll => Get(nameof(Metric_Scroll));
public static string Stats_PeakKpsTooltipLabel => Get(nameof(Stats_PeakKpsTooltipLabel));
public static string Stats_PeakCpsTooltipLabel => Get(nameof(Stats_PeakCpsTooltipLabel));
+ public static string FloatingStats_WindowTitle => Get(nameof(FloatingStats_WindowTitle));
+ public static string FloatingStats_Today => Get(nameof(FloatingStats_Today));
+ public static string FloatingStats_PrimaryMetric => Get(nameof(FloatingStats_PrimaryMetric));
+ public static string FloatingStats_SecondaryMetric => Get(nameof(FloatingStats_SecondaryMetric));
+ public static string FloatingStats_Layout => Get(nameof(FloatingStats_Layout));
+ public static string FloatingStats_FontSize => Get(nameof(FloatingStats_FontSize));
+ public static string FloatingStats_SingleRow => Get(nameof(FloatingStats_SingleRow));
+ public static string FloatingStats_DoubleRow => Get(nameof(FloatingStats_DoubleRow));
+ public static string FloatingStats_AlwaysOnTop => Get(nameof(FloatingStats_AlwaysOnTop));
+ public static string FloatingStats_LockPosition => Get(nameof(FloatingStats_LockPosition));
+ public static string FloatingStats_OpenDetails => Get(nameof(FloatingStats_OpenDetails));
+ public static string FloatingStats_Hide => Get(nameof(FloatingStats_Hide));
public static string AppStats_WindowTitle => Get(nameof(AppStats_WindowTitle));
public static string AppStats_HeaderTitle => Get(nameof(AppStats_HeaderTitle));
diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.resx b/KeyStats.Windows/KeyStats/Properties/Strings.resx
index 6be7731..cab5616 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx
@@ -68,11 +68,27 @@
Today's clicks reached {0:N0}.
Open Main Window
+ Show Today's Floating Stats
Settings
Start at Login
Key History
Quit
+ Today's Stats - KeyStats
+ TODAY
+ First Metric
+ Second Metric
+ Display Layout
+ Font Size
+ One Row
+ Two Rows
+ Always on Top
+ Lock Position
+ Open Detailed Stats
+ Hide Floating Window
+ Floating Stats
+ Choose the two metrics shown in the desktop floating window and adjust its behavior.
+
Export Successful
Data saved to {0}
Export Failed
diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
index cbd3005..3c5d93c 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx
@@ -68,11 +68,27 @@
今日点击数已达到 {0:N0} 次
打开主界面
+ 显示今日统计浮窗
设置
开机启动
历史按键统计
退出
+ 今日统计 - KeyStats
+ 今日
+ 第一项
+ 第二项
+ 展示方式
+ 字体大小
+ 一排
+ 两排
+ 始终置顶
+ 锁定位置
+ 打开详细统计
+ 隐藏浮窗
+ 统计浮窗
+ 选择桌面浮窗显示的两项统计,并调整浮窗行为。
+
导出成功
数据已保存到 {0}
导出失败
diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
index d6fc94b..d40783c 100644
--- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
+++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx
@@ -68,11 +68,27 @@
今日點擊數已達 {0:N0} 次。
開啟主視窗
+ 顯示今日統計浮窗
設定
登入時啟動
按鍵歷史
結束
+ 今日統計 - KeyStats
+ 今日
+ 第一項
+ 第二項
+ 顯示方式
+ 字型大小
+ 單排
+ 雙排
+ 永遠置頂
+ 鎖定位置
+ 開啟詳細統計
+ 隱藏浮窗
+ 統計浮窗
+ 選擇桌面浮窗顯示的兩項統計,並調整浮窗行為。
+
匯出成功
資料已儲存至 {0}
匯出失敗
diff --git a/KeyStats.Windows/KeyStats/Services/StatsManager.cs b/KeyStats.Windows/KeyStats/Services/StatsManager.cs
index 55c64e3..e763ede 100644
--- a/KeyStats.Windows/KeyStats/Services/StatsManager.cs
+++ b/KeyStats.Windows/KeyStats/Services/StatsManager.cs
@@ -13,6 +13,32 @@ namespace KeyStats.Services;
public class StatsManager : IDisposable
{
+ public readonly struct CurrentStatsSnapshot
+ {
+ public CurrentStatsSnapshot(DailyStats stats)
+ {
+ KeyPresses = stats.KeyPresses;
+ TotalClicks = stats.TotalClicks;
+ LeftClicks = stats.LeftClicks;
+ RightClicks = stats.RightClicks;
+ MiddleClicks = stats.MiddleClicks;
+ MouseDistance = stats.MouseDistance;
+ ScrollDistance = stats.ScrollDistance;
+ PeakKPS = stats.PeakKPS;
+ PeakCPS = stats.PeakCPS;
+ }
+
+ public int KeyPresses { get; }
+ public int TotalClicks { get; }
+ public int LeftClicks { get; }
+ public int RightClicks { get; }
+ public int MiddleClicks { get; }
+ public double MouseDistance { get; }
+ public double ScrollDistance { get; }
+ public double PeakKPS { get; }
+ public double PeakCPS { get; }
+ }
+
public enum StatsUpdateKind
{
Full,
@@ -1295,6 +1321,17 @@ public string FormatNumber(int number)
return number.ToString("N0");
}
+ ///
+ /// Returns a lock-protected snapshot of today's local statistics for UI projection.
+ ///
+ public CurrentStatsSnapshot GetCurrentStatsSnapshot()
+ {
+ lock (_lock)
+ {
+ return new CurrentStatsSnapshot(CurrentStats);
+ }
+ }
+
public List<(string Key, int Count)> GetKeyPressBreakdownSorted()
{
lock (_lock)
@@ -1937,7 +1974,7 @@ public string FormatMouseDistance(double distance)
return $"{meters * 100:F1} cm";
}
- private string FormatScrollDistance(double distance)
+ public string FormatScrollDistance(double distance)
{
if (distance >= 10000)
return $"{distance / 1000:F1} k";
diff --git a/KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs b/KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs
new file mode 100644
index 0000000..4acc822
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs
@@ -0,0 +1,335 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Windows;
+using KeyStats.Services;
+
+namespace KeyStats.ViewModels;
+
+public sealed class FloatingStatsViewModel : ViewModelBase
+{
+ private static event Action? MetricSettingsChanged;
+
+ public const string KeyPressesMetric = "keyPresses";
+ public const string TotalClicksMetric = "totalClicks";
+ public const string LeftClicksMetric = "leftClicks";
+ public const string RightClicksMetric = "rightClicks";
+ public const string MiddleClicksMetric = "middleClicks";
+ public const string MouseDistanceMetric = "mouseDistance";
+ public const string ScrollDistanceMetric = "scrollDistance";
+ public const string PeakKpsMetric = "peakKps";
+ public const string PeakCpsMetric = "peakCps";
+
+ private static readonly string[] MetricIds =
+ {
+ KeyPressesMetric,
+ TotalClicksMetric,
+ LeftClicksMetric,
+ RightClicksMetric,
+ MiddleClicksMetric,
+ MouseDistanceMetric,
+ ScrollDistanceMetric,
+ PeakKpsMetric,
+ PeakCpsMetric
+ };
+
+ private string _primaryLabel = string.Empty;
+ private string _primaryIcon = string.Empty;
+ private string _primaryValue = "0";
+ private string _primaryFullValue = "0";
+ private string _secondaryLabel = string.Empty;
+ private string _secondaryIcon = string.Empty;
+ private string _secondaryValue = "0";
+ private string _secondaryFullValue = "0";
+ private bool _isCleanedUp;
+
+ public FloatingStatsViewModel()
+ {
+ NormalizeMetricSettings();
+ Refresh();
+ StatsManager.Instance.StatsChanged += OnStatsChanged;
+ MetricSettingsChanged += OnMetricSettingsChanged;
+ }
+
+ public static IReadOnlyList AvailableMetricIds => MetricIds;
+
+ public string PrimaryLabel
+ {
+ get => _primaryLabel;
+ private set => SetProperty(ref _primaryLabel, value);
+ }
+
+ public string PrimaryIcon
+ {
+ get => _primaryIcon;
+ private set => SetProperty(ref _primaryIcon, value);
+ }
+
+ public string PrimaryValue
+ {
+ get => _primaryValue;
+ private set => SetProperty(ref _primaryValue, value);
+ }
+
+ public string PrimaryFullValue
+ {
+ get => _primaryFullValue;
+ private set => SetProperty(ref _primaryFullValue, value);
+ }
+
+ public string SecondaryLabel
+ {
+ get => _secondaryLabel;
+ private set => SetProperty(ref _secondaryLabel, value);
+ }
+
+ public string SecondaryIcon
+ {
+ get => _secondaryIcon;
+ private set => SetProperty(ref _secondaryIcon, value);
+ }
+
+ public string SecondaryValue
+ {
+ get => _secondaryValue;
+ private set => SetProperty(ref _secondaryValue, value);
+ }
+
+ public string SecondaryFullValue
+ {
+ get => _secondaryFullValue;
+ private set => SetProperty(ref _secondaryFullValue, value);
+ }
+
+ public string PrimaryMetricId => StatsManager.Instance.Settings.FloatingStatsPrimaryMetric;
+
+ public string SecondaryMetricId => StatsManager.Instance.Settings.FloatingStatsSecondaryMetric;
+
+ public static bool IsValidMetric(string? metricId)
+ {
+ if (string.IsNullOrWhiteSpace(metricId))
+ {
+ return false;
+ }
+
+ foreach (var candidate in MetricIds)
+ {
+ if (string.Equals(candidate, metricId, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static string GetMetricLabel(string metricId)
+ {
+ return metricId switch
+ {
+ KeyPressesMetric => KeyStats.Properties.Strings.Stats_KeyPresses,
+ TotalClicksMetric => KeyStats.Properties.Strings.Stats_MouseClicks,
+ LeftClicksMetric => KeyStats.Properties.Strings.Click_Left,
+ RightClicksMetric => KeyStats.Properties.Strings.Click_Right,
+ MiddleClicksMetric => KeyStats.Properties.Strings.Click_Middle,
+ MouseDistanceMetric => KeyStats.Properties.Strings.Stats_MouseDistance,
+ ScrollDistanceMetric => KeyStats.Properties.Strings.Stats_ScrollDistance,
+ PeakKpsMetric => KeyStats.Properties.Strings.Stats_PeakKpsTooltipLabel,
+ PeakCpsMetric => KeyStats.Properties.Strings.Stats_PeakCpsTooltipLabel,
+ _ => KeyStats.Properties.Strings.Stats_KeyPresses
+ };
+ }
+
+ public bool SetMetric(bool isPrimary, string metricId)
+ {
+ return UpdateMetricSetting(isPrimary, metricId);
+ }
+
+ public static bool UpdateMetricSetting(bool isPrimary, string metricId)
+ {
+ if (!IsValidMetric(metricId))
+ {
+ return false;
+ }
+
+ var settings = StatsManager.Instance.Settings;
+ var otherMetric = isPrimary
+ ? settings.FloatingStatsSecondaryMetric
+ : settings.FloatingStatsPrimaryMetric;
+ if (string.Equals(metricId, otherMetric, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (isPrimary)
+ {
+ if (string.Equals(settings.FloatingStatsPrimaryMetric, metricId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ settings.FloatingStatsPrimaryMetric = metricId;
+ }
+ else
+ {
+ if (string.Equals(settings.FloatingStatsSecondaryMetric, metricId, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ settings.FloatingStatsSecondaryMetric = metricId;
+ }
+
+ StatsManager.Instance.SaveSettings();
+ MetricSettingsChanged?.Invoke();
+ return true;
+ }
+
+ public void Cleanup()
+ {
+ if (_isCleanedUp)
+ {
+ return;
+ }
+
+ _isCleanedUp = true;
+ StatsManager.Instance.StatsChanged -= OnStatsChanged;
+ MetricSettingsChanged -= OnMetricSettingsChanged;
+ }
+
+ private void NormalizeMetricSettings()
+ {
+ var settings = StatsManager.Instance.Settings;
+ var changed = false;
+
+ if (!IsValidMetric(settings.FloatingStatsPrimaryMetric))
+ {
+ settings.FloatingStatsPrimaryMetric = KeyPressesMetric;
+ changed = true;
+ }
+
+ if (!IsValidMetric(settings.FloatingStatsSecondaryMetric) ||
+ string.Equals(
+ settings.FloatingStatsPrimaryMetric,
+ settings.FloatingStatsSecondaryMetric,
+ StringComparison.Ordinal))
+ {
+ settings.FloatingStatsSecondaryMetric = TotalClicksMetric;
+ if (string.Equals(
+ settings.FloatingStatsPrimaryMetric,
+ settings.FloatingStatsSecondaryMetric,
+ StringComparison.Ordinal))
+ {
+ settings.FloatingStatsSecondaryMetric = LeftClicksMetric;
+ }
+
+ changed = true;
+ }
+
+ if (changed)
+ {
+ StatsManager.Instance.SaveSettings();
+ }
+ }
+
+ private void OnStatsChanged(StatsManager.StatsUpdateKind _)
+ {
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher == null || dispatcher.CheckAccess())
+ {
+ Refresh();
+ return;
+ }
+
+ dispatcher.BeginInvoke(new Action(Refresh));
+ }
+
+ private void OnMetricSettingsChanged()
+ {
+ var dispatcher = Application.Current?.Dispatcher;
+ if (dispatcher == null || dispatcher.CheckAccess())
+ {
+ Refresh();
+ return;
+ }
+
+ dispatcher.BeginInvoke(new Action(Refresh));
+ }
+
+ private void Refresh()
+ {
+ if (_isCleanedUp)
+ {
+ return;
+ }
+
+ var manager = StatsManager.Instance;
+ var stats = manager.GetCurrentStatsSnapshot();
+ var primary = CreatePresentation(PrimaryMetricId, stats, manager);
+ var secondary = CreatePresentation(SecondaryMetricId, stats, manager);
+
+ PrimaryLabel = primary.Label;
+ PrimaryIcon = primary.Icon;
+ PrimaryValue = primary.CompactValue;
+ PrimaryFullValue = primary.FullValue;
+ SecondaryLabel = secondary.Label;
+ SecondaryIcon = secondary.Icon;
+ SecondaryValue = secondary.CompactValue;
+ SecondaryFullValue = secondary.FullValue;
+ }
+
+ private static (string Label, string Icon, string CompactValue, string FullValue) CreatePresentation(
+ string metricId,
+ StatsManager.CurrentStatsSnapshot stats,
+ StatsManager manager)
+ {
+ var label = GetMetricLabel(metricId);
+ var icon = metricId is KeyPressesMetric or PeakKpsMetric ? "\uE765" : "\uE8B0";
+ string compactValue;
+ string fullValue;
+
+ switch (metricId)
+ {
+ case TotalClicksMetric:
+ compactValue = manager.FormatNumber(stats.TotalClicks);
+ fullValue = stats.TotalClicks.ToString("N0", CultureInfo.CurrentCulture);
+ break;
+ case LeftClicksMetric:
+ compactValue = manager.FormatNumber(stats.LeftClicks);
+ fullValue = stats.LeftClicks.ToString("N0", CultureInfo.CurrentCulture);
+ break;
+ case RightClicksMetric:
+ compactValue = manager.FormatNumber(stats.RightClicks);
+ fullValue = stats.RightClicks.ToString("N0", CultureInfo.CurrentCulture);
+ break;
+ case MiddleClicksMetric:
+ compactValue = manager.FormatNumber(stats.MiddleClicks);
+ fullValue = stats.MiddleClicks.ToString("N0", CultureInfo.CurrentCulture);
+ break;
+ case MouseDistanceMetric:
+ compactValue = manager.FormatMouseDistance(stats.MouseDistance);
+ fullValue = compactValue;
+ break;
+ case ScrollDistanceMetric:
+ compactValue = manager.FormatScrollDistance(stats.ScrollDistance);
+ fullValue = compactValue;
+ break;
+ case PeakKpsMetric:
+ compactValue = Math.Round(stats.PeakKPS, MidpointRounding.AwayFromZero)
+ .ToString("N0", CultureInfo.CurrentCulture);
+ fullValue = compactValue;
+ break;
+ case PeakCpsMetric:
+ compactValue = Math.Round(stats.PeakCPS, MidpointRounding.AwayFromZero)
+ .ToString("N0", CultureInfo.CurrentCulture);
+ fullValue = compactValue;
+ break;
+ default:
+ compactValue = manager.FormatNumber(stats.KeyPresses);
+ fullValue = stats.KeyPresses.ToString("N0", CultureInfo.CurrentCulture);
+ break;
+ }
+
+ return (label, icon, compactValue, $"{label}: {fullValue}");
+ }
+}
diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml
new file mode 100644
index 0000000..844a825
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs
new file mode 100644
index 0000000..c2e2c44
--- /dev/null
+++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs
@@ -0,0 +1,439 @@
+using System;
+using System.Collections.Generic;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Threading;
+using KeyStats.Helpers;
+using KeyStats.Models;
+using KeyStats.Services;
+using KeyStats.ViewModels;
+using Microsoft.Win32;
+using Forms = System.Windows.Forms;
+
+namespace KeyStats.Views;
+
+public partial class FloatingStatsWindow : Window
+{
+ private const double EdgeMargin = 16;
+ private const double SingleRowWidth = 72;
+ private const double SingleRowHeight = 28;
+ private const double DoubleRowWidth = 32;
+ private const double DoubleRowHeight = 38;
+ private readonly FloatingStatsViewModel _viewModel;
+ private readonly DispatcherTimer _positionSaveTimer;
+ private bool _isLoaded;
+ private bool _isRestoringPosition;
+
+ public FloatingStatsWindow()
+ {
+ InitializeComponent();
+ _viewModel = new FloatingStatsViewModel();
+ DataContext = _viewModel;
+ _positionSaveTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromMilliseconds(400)
+ };
+ _positionSaveTimer.Tick += PositionSaveTimer_Tick;
+
+ var settings = StatsManager.Instance.Settings;
+ Topmost = settings.FloatingStatsTopmost;
+ UpdateDragCursor();
+ ApplyFontSettings();
+ ApplyLayoutSettings();
+
+ SourceInitialized += OnSourceInitialized;
+ Loaded += OnLoaded;
+ Closed += OnClosed;
+ LocationChanged += OnLocationChanged;
+ ThemeManager.Instance.ThemeChanged += OnThemeChanged;
+ SystemEvents.DisplaySettingsChanged += OnDisplaySettingsChanged;
+ }
+
+ public void ShowWindow()
+ {
+ if (!IsVisible)
+ {
+ Show();
+ }
+ }
+
+ public void ApplyBehaviorSettings()
+ {
+ var settings = StatsManager.Instance.Settings;
+ Topmost = settings.FloatingStatsTopmost;
+ UpdateDragCursor();
+ ApplyFontSettings();
+ if (ApplyLayoutSettings())
+ {
+ EnsureVisiblePosition();
+ }
+ }
+
+ private void OnSourceInitialized(object? sender, EventArgs e)
+ {
+ ApplySurface();
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ RestorePosition();
+ RootBorder.ContextMenu = BuildContextMenu();
+ _isLoaded = true;
+
+ App.CurrentApp?.TrackPageView("floating_stats", new Dictionary
+ {
+ ["primary_metric"] = _viewModel.PrimaryMetricId,
+ ["secondary_metric"] = _viewModel.SecondaryMetricId,
+ ["layout"] = StatsManager.Instance.Settings.FloatingStatsLayoutMode
+ });
+ }
+
+ private void OnClosed(object? sender, EventArgs e)
+ {
+ _positionSaveTimer.Stop();
+ ThemeManager.Instance.ThemeChanged -= OnThemeChanged;
+ SystemEvents.DisplaySettingsChanged -= OnDisplaySettingsChanged;
+ _viewModel.Cleanup();
+ }
+
+ private void OnThemeChanged()
+ {
+ Dispatcher.BeginInvoke(new Action(ApplySurface));
+ }
+
+ private void OnDisplaySettingsChanged(object? sender, EventArgs e)
+ {
+ Dispatcher.BeginInvoke(new Action(EnsureVisiblePosition));
+ }
+
+ private void ApplySurface()
+ {
+ RootBorder.SetResourceReference(
+ Border.BackgroundProperty,
+ "FloatingStatsSurfaceBrush");
+ RootBorder.SetResourceReference(
+ Border.BorderBrushProperty,
+ "TrayPopupBorderBrush");
+ }
+
+ private void RootBorder_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
+ {
+ if (e.ChangedButton != MouseButton.Left)
+ {
+ return;
+ }
+
+ if (e.ClickCount == 2)
+ {
+ App.CurrentApp?.TrackClick("floating_stats_open_details");
+ App.CurrentApp?.ShowMainWindow();
+ e.Handled = true;
+ return;
+ }
+
+ if (StatsManager.Instance.Settings.FloatingStatsPositionLocked)
+ {
+ return;
+ }
+
+ try
+ {
+ DragMove();
+ }
+ catch (InvalidOperationException)
+ {
+ // The mouse may be released before WPF enters the native drag loop.
+ }
+ finally
+ {
+ EnsureVisiblePosition();
+ }
+ }
+
+ private ContextMenu BuildContextMenu()
+ {
+ var menu = new ContextMenu();
+ var lockPositionItem = new MenuItem
+ {
+ Header = KeyStats.Properties.Strings.FloatingStats_LockPosition,
+ IsCheckable = true,
+ IsChecked = StatsManager.Instance.Settings.FloatingStatsPositionLocked
+ };
+ lockPositionItem.Click += (_, _) =>
+ {
+ var isLocked = lockPositionItem.IsChecked;
+ var settings = StatsManager.Instance.Settings;
+ settings.FloatingStatsPositionLocked = isLocked;
+ StatsManager.Instance.SaveSettings();
+ ApplyBehaviorSettings();
+ App.CurrentApp?.TrackClick("floating_stats_position_lock", new Dictionary
+ {
+ ["enabled"] = isLocked
+ });
+ };
+ menu.Items.Add(lockPositionItem);
+
+ var hideItem = new MenuItem
+ {
+ Header = KeyStats.Properties.Strings.FloatingStats_Hide
+ };
+ hideItem.Click += (_, _) =>
+ {
+ App.CurrentApp?.TrackClick("floating_stats_hide");
+ App.CurrentApp?.SetFloatingStatsVisible(false);
+ };
+ menu.Items.Add(hideItem);
+ menu.Items.Add(new Separator());
+
+ var settingsItem = new MenuItem
+ {
+ Header = KeyStats.Properties.Strings.Tray_Settings
+ };
+ settingsItem.Click += (_, _) =>
+ {
+ App.CurrentApp?.TrackClick("floating_stats_settings");
+ App.CurrentApp?.ShowSettingsWindow();
+ };
+ menu.Items.Add(settingsItem);
+ menu.Opened += (_, _) =>
+ {
+ lockPositionItem.IsChecked = StatsManager.Instance.Settings.FloatingStatsPositionLocked;
+ };
+
+ return menu;
+ }
+
+ private void UpdateDragCursor()
+ {
+ RootBorder.Cursor = StatsManager.Instance.Settings.FloatingStatsPositionLocked
+ ? Cursors.Arrow
+ : Cursors.SizeAll;
+ }
+
+ private void ApplyFontSettings()
+ {
+ var fontSize = StatsManager.Instance.Settings.FloatingStatsFontSize;
+ SinglePrimaryValueTextBlock.FontSize = fontSize;
+ SingleSecondaryValueTextBlock.FontSize = fontSize;
+ DoublePrimaryValueTextBlock.FontSize = fontSize;
+ DoubleSecondaryValueTextBlock.FontSize = fontSize;
+ }
+
+ private bool ApplyLayoutSettings()
+ {
+ var settings = StatsManager.Instance.Settings;
+ var useDoubleRow = string.Equals(
+ settings.FloatingStatsLayoutMode,
+ AppSettings.FloatingStatsDoubleRowLayoutMode,
+ StringComparison.Ordinal);
+ var layoutScale = settings.FloatingStatsFontSize / (double)AppSettings.FloatingStatsLayoutBaseFontSize;
+ var baseWidth = useDoubleRow ? DoubleRowWidth : SingleRowWidth;
+ var baseHeight = useDoubleRow ? DoubleRowHeight : SingleRowHeight;
+ var targetWidth = Math.Round(baseWidth * layoutScale, MidpointRounding.AwayFromZero);
+ var targetHeight = Math.Round(baseHeight * layoutScale, MidpointRounding.AwayFromZero);
+ var sizeChanged = !Width.Equals(targetWidth) || !Height.Equals(targetHeight);
+
+ SingleRowLayout.Visibility = useDoubleRow ? Visibility.Collapsed : Visibility.Visible;
+ DoubleRowLayout.Visibility = useDoubleRow ? Visibility.Visible : Visibility.Collapsed;
+ Width = targetWidth;
+ Height = targetHeight;
+ return sizeChanged;
+ }
+
+ private void OnLocationChanged(object? sender, EventArgs e)
+ {
+ if (!_isLoaded || _isRestoringPosition)
+ {
+ return;
+ }
+
+ ClampCurrentPositionToWorkingArea();
+ _positionSaveTimer.Stop();
+ _positionSaveTimer.Start();
+ }
+
+ private void PositionSaveTimer_Tick(object? sender, EventArgs e)
+ {
+ _positionSaveTimer.Stop();
+ SaveCurrentPosition();
+ }
+
+ private void SaveCurrentPosition()
+ {
+ var settings = StatsManager.Instance.Settings;
+ settings.FloatingStatsLeft = Left;
+ settings.FloatingStatsTop = Top;
+ var monitorDeviceName = GetCurrentMonitorDeviceName();
+ if (!string.IsNullOrWhiteSpace(monitorDeviceName))
+ {
+ settings.FloatingStatsMonitorDeviceName = monitorDeviceName;
+ }
+ StatsManager.Instance.SaveSettings();
+ }
+
+ private void RestorePosition()
+ {
+ var workingAreas = GetWorkingAreasInDips();
+ var primaryArea = workingAreas.Count > 0
+ ? workingAreas[0]
+ : new WorkingAreaInfo(string.Empty, SystemParameters.WorkArea);
+ var settings = StatsManager.Instance.Settings;
+ var savedArea = FindWorkingAreaByDeviceName(
+ settings.FloatingStatsMonitorDeviceName,
+ workingAreas);
+ var preferredArea = savedArea ?? primaryArea;
+ var requestedBounds = settings.FloatingStatsLeft.HasValue && settings.FloatingStatsTop.HasValue
+ ? new Rect(settings.FloatingStatsLeft.Value, settings.FloatingStatsTop.Value, Width, Height)
+ : new Rect(
+ preferredArea.Bounds.Right - Width - EdgeMargin,
+ preferredArea.Bounds.Top + EdgeMargin,
+ Width,
+ Height);
+
+ var targetArea = savedArea ?? FindBestWorkingArea(requestedBounds, workingAreas) ?? preferredArea;
+ var clamped = ClampToArea(requestedBounds, targetArea.Bounds);
+
+ _isRestoringPosition = true;
+ try
+ {
+ Left = clamped.Left;
+ Top = clamped.Top;
+ }
+ finally
+ {
+ _isRestoringPosition = false;
+ }
+ }
+
+ private void EnsureVisiblePosition()
+ {
+ if (!_isLoaded)
+ {
+ return;
+ }
+
+ ClampCurrentPositionToWorkingArea();
+ _positionSaveTimer.Stop();
+ SaveCurrentPosition();
+ }
+
+ private void ClampCurrentPositionToWorkingArea()
+ {
+ var workingAreas = GetWorkingAreasInDips();
+ var preferredArea = workingAreas.Count > 0
+ ? workingAreas[0]
+ : new WorkingAreaInfo(string.Empty, SystemParameters.WorkArea);
+ var bounds = new Rect(Left, Top, Width, Height);
+ var currentArea = FindWorkingAreaByDeviceName(GetCurrentMonitorDeviceName(), workingAreas);
+ var targetArea = currentArea ?? FindBestWorkingArea(bounds, workingAreas) ?? preferredArea;
+ var clamped = ClampToArea(bounds, targetArea.Bounds);
+
+ _isRestoringPosition = true;
+ try
+ {
+ Left = clamped.Left;
+ Top = clamped.Top;
+ }
+ finally
+ {
+ _isRestoringPosition = false;
+ }
+ }
+
+ private List GetWorkingAreasInDips()
+ {
+ var areas = new List();
+ var source = PresentationSource.FromVisual(this);
+ var fallbackTransform = source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity;
+
+ foreach (var screen in Forms.Screen.AllScreens)
+ {
+ var area = new WorkingAreaInfo(
+ screen.DeviceName,
+ MonitorGeometryHelper.GetWorkingAreaInDips(screen, fallbackTransform));
+ if (screen.Primary)
+ {
+ areas.Insert(0, area);
+ }
+ else
+ {
+ areas.Add(area);
+ }
+ }
+
+ return areas;
+ }
+
+ private string? GetCurrentMonitorDeviceName()
+ {
+ var handle = new WindowInteropHelper(this).Handle;
+ return handle == IntPtr.Zero
+ ? null
+ : Forms.Screen.FromHandle(handle).DeviceName;
+ }
+
+ private static WorkingAreaInfo? FindWorkingAreaByDeviceName(
+ string? deviceName,
+ IReadOnlyList workingAreas)
+ {
+ if (string.IsNullOrWhiteSpace(deviceName))
+ {
+ return null;
+ }
+
+ foreach (var area in workingAreas)
+ {
+ if (string.Equals(area.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase))
+ {
+ return area;
+ }
+ }
+
+ return null;
+ }
+
+ private static WorkingAreaInfo? FindBestWorkingArea(
+ Rect bounds,
+ IReadOnlyList workingAreas)
+ {
+ WorkingAreaInfo? bestArea = null;
+ var bestIntersection = 0.0;
+ foreach (var area in workingAreas)
+ {
+ var intersection = Rect.Intersect(bounds, area.Bounds);
+ var intersectionSize = intersection.IsEmpty ? 0 : intersection.Width * intersection.Height;
+ if (intersectionSize <= bestIntersection)
+ {
+ continue;
+ }
+
+ bestIntersection = intersectionSize;
+ bestArea = area;
+ }
+
+ return bestArea;
+ }
+
+ private static Rect ClampToArea(Rect bounds, Rect workingArea)
+ {
+ var left = Math.Max(workingArea.Left, Math.Min(bounds.Left, workingArea.Right - bounds.Width));
+ var top = Math.Max(workingArea.Top, Math.Min(bounds.Top, workingArea.Bottom - bounds.Height));
+ return new Rect(left, top, bounds.Width, bounds.Height);
+ }
+
+ private sealed class WorkingAreaInfo
+ {
+ public WorkingAreaInfo(string deviceName, Rect bounds)
+ {
+ DeviceName = deviceName;
+ Bounds = bounds;
+ }
+
+ public string DeviceName { get; }
+
+ public Rect Bounds { get; }
+ }
+}
diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml
index b9e636e..3c28d7c 100644
--- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml
+++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml
@@ -9,7 +9,9 @@
Background="{DynamicResource WindowSurfaceBrush}"
ShowInTaskbar="True">
-
+
+
@@ -99,6 +101,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -148,5 +238,6 @@
FontSize="11"
Foreground="{DynamicResource TextSecondaryBrush}"/>
-
+
+
diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs
index 5b38d7e..c2f4245 100644
--- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs
+++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs
@@ -3,27 +3,38 @@
using System.Reflection;
using System.Windows;
using System.Windows.Controls;
+using System.Windows.Interop;
+using System.Windows.Media;
using KeyStats.Helpers;
+using KeyStats.Models;
using KeyStats.Services;
+using KeyStats.ViewModels;
+using Forms = System.Windows.Forms;
namespace KeyStats.Views;
public partial class SettingsWindow : Window
{
private const string GitHubUrl = "https://github.com/debugtheworldbot/keyStats";
+ private const double WindowEdgeMargin = 16;
+ private bool _isLoadingFloatingStats = true;
public SettingsWindow()
{
InitializeComponent();
+ MaxHeight = System.Math.Max(1, SystemParameters.WorkArea.Height - WindowEdgeMargin * 2);
VersionTextBlock.Text = string.Format(KeyStats.Properties.Strings.Settings_VersionFormat, GetDisplayVersion());
Loaded += OnLoaded;
Closed += OnClosed;
+ LocationChanged += OnLocationChanged;
ThemeManager.Instance.ThemeChanged += OnThemeChanged;
}
private void OnLoaded(object sender, RoutedEventArgs e)
{
+ UpdateMaximumHeight();
ApplyWindowBackdrop();
+ LoadFloatingStatsControls();
if (App.CurrentApp?.SyncCoordinator != null)
{
App.CurrentApp.SyncCoordinator.StatusChanged += OnSyncStatusChanged;
@@ -35,6 +46,7 @@ private void OnLoaded(object sender, RoutedEventArgs e)
private void OnClosed(object? sender, System.EventArgs e)
{
ThemeManager.Instance.ThemeChanged -= OnThemeChanged;
+ LocationChanged -= OnLocationChanged;
if (App.CurrentApp?.SyncCoordinator != null)
{
App.CurrentApp.SyncCoordinator.StatusChanged -= OnSyncStatusChanged;
@@ -51,6 +63,26 @@ private void ApplyWindowBackdrop()
WindowBackdropHelper.Apply(this, NativeInterop.DwmSystemBackdropType.TransientWindow);
}
+ private void OnLocationChanged(object? sender, System.EventArgs e)
+ {
+ UpdateMaximumHeight();
+ }
+
+ private void UpdateMaximumHeight()
+ {
+ var handle = new WindowInteropHelper(this).Handle;
+ if (handle == System.IntPtr.Zero)
+ {
+ return;
+ }
+
+ var source = PresentationSource.FromVisual(this);
+ var fallbackTransform = source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity;
+ var screen = Forms.Screen.FromHandle(handle);
+ var workingArea = MonitorGeometryHelper.GetWorkingAreaInDips(screen, fallbackTransform);
+ MaxHeight = System.Math.Max(1, workingArea.Height - WindowEdgeMargin * 2);
+ }
+
private static string GetDisplayVersion()
{
var assembly = typeof(App).Assembly;
@@ -145,6 +177,152 @@ private void NotificationSettings_Click(object sender, RoutedEventArgs e)
App.CurrentApp?.ShowNotificationSettings();
}
+ private void LoadFloatingStatsControls()
+ {
+ _isLoadingFloatingStats = true;
+ var options = FloatingStatsViewModel.AvailableMetricIds
+ .Select(metricId => new FloatingMetricOption(
+ metricId,
+ FloatingStatsViewModel.GetMetricLabel(metricId)))
+ .ToList();
+ FloatingPrimaryMetricComboBox.ItemsSource = options;
+ FloatingSecondaryMetricComboBox.ItemsSource = options;
+ FloatingFontSizeComboBox.ItemsSource = Enumerable.Range(
+ AppSettings.MinimumFloatingStatsFontSize,
+ AppSettings.MaximumFloatingStatsFontSize - AppSettings.MinimumFloatingStatsFontSize + 1);
+ RefreshFloatingStatsControls();
+ _isLoadingFloatingStats = false;
+ }
+
+ private void RefreshFloatingStatsControls()
+ {
+ var settings = StatsManager.Instance.Settings;
+ FloatingPrimaryMetricComboBox.SelectedValue = settings.FloatingStatsPrimaryMetric;
+ FloatingSecondaryMetricComboBox.SelectedValue = settings.FloatingStatsSecondaryMetric;
+ var layoutMode = string.Equals(
+ settings.FloatingStatsLayoutMode,
+ AppSettings.FloatingStatsDoubleRowLayoutMode,
+ System.StringComparison.Ordinal)
+ ? AppSettings.FloatingStatsDoubleRowLayoutMode
+ : AppSettings.FloatingStatsSingleRowLayoutMode;
+ FloatingLayoutComboBox.SelectedItem = FloatingLayoutComboBox.Items
+ .Cast()
+ .FirstOrDefault(item => string.Equals(
+ item.Tag as string,
+ layoutMode,
+ System.StringComparison.Ordinal))
+ ?? FloatingLayoutComboBox.Items[0];
+ FloatingTopmostCheckBox.IsChecked = settings.FloatingStatsTopmost;
+ FloatingLockPositionCheckBox.IsChecked = settings.FloatingStatsPositionLocked;
+ FloatingFontSizeComboBox.SelectedItem = settings.FloatingStatsFontSize;
+ }
+
+ private void FloatingMetric_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_isLoadingFloatingStats || sender is not ComboBox comboBox)
+ {
+ return;
+ }
+
+ var isPrimary = ReferenceEquals(comboBox, FloatingPrimaryMetricComboBox);
+ if (comboBox.SelectedValue is not string metricId || string.IsNullOrWhiteSpace(metricId))
+ {
+ return;
+ }
+
+ if (!FloatingStatsViewModel.UpdateMetricSetting(isPrimary, metricId))
+ {
+ _isLoadingFloatingStats = true;
+ RefreshFloatingStatsControls();
+ _isLoadingFloatingStats = false;
+ return;
+ }
+
+ App.CurrentApp?.TrackClick("settings_floating_stats_metric_change", new System.Collections.Generic.Dictionary
+ {
+ ["slot"] = isPrimary ? "primary" : "secondary",
+ ["metric"] = metricId
+ });
+ }
+
+ private void FloatingLayout_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_isLoadingFloatingStats || FloatingLayoutComboBox.SelectedItem is not ComboBoxItem selectedItem)
+ {
+ return;
+ }
+
+ if (selectedItem.Tag is not string layoutMode)
+ {
+ return;
+ }
+
+ if (!string.Equals(layoutMode, AppSettings.FloatingStatsSingleRowLayoutMode, System.StringComparison.Ordinal) &&
+ !string.Equals(layoutMode, AppSettings.FloatingStatsDoubleRowLayoutMode, System.StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ var settings = StatsManager.Instance.Settings;
+ if (string.Equals(settings.FloatingStatsLayoutMode, layoutMode, System.StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ settings.FloatingStatsLayoutMode = layoutMode;
+ StatsManager.Instance.SaveSettings();
+ App.CurrentApp?.ApplyFloatingStatsBehaviorSettings();
+ App.CurrentApp?.TrackClick("settings_floating_stats_layout", new System.Collections.Generic.Dictionary
+ {
+ ["layout"] = layoutMode
+ });
+ }
+
+ private void FloatingStatsBehavior_Changed(object sender, RoutedEventArgs e)
+ {
+ if (_isLoadingFloatingStats)
+ {
+ return;
+ }
+
+ var settings = StatsManager.Instance.Settings;
+ settings.FloatingStatsTopmost = FloatingTopmostCheckBox.IsChecked == true;
+ settings.FloatingStatsPositionLocked = FloatingLockPositionCheckBox.IsChecked == true;
+ StatsManager.Instance.SaveSettings();
+ App.CurrentApp?.ApplyFloatingStatsBehaviorSettings();
+
+ var eventName = ReferenceEquals(sender, FloatingTopmostCheckBox)
+ ? "settings_floating_stats_topmost"
+ : "settings_floating_stats_position_lock";
+ var enabled = sender is CheckBox checkBox && checkBox.IsChecked == true;
+ App.CurrentApp?.TrackClick(eventName, new System.Collections.Generic.Dictionary
+ {
+ ["enabled"] = enabled
+ });
+ }
+
+ private void FloatingFontSize_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (_isLoadingFloatingStats || FloatingFontSizeComboBox.SelectedItem is not int fontSize)
+ {
+ return;
+ }
+
+ var settings = StatsManager.Instance.Settings;
+ if (settings.FloatingStatsFontSize == fontSize)
+ {
+ return;
+ }
+
+ settings.FloatingStatsFontSize = fontSize;
+ StatsManager.Instance.SaveSettings();
+ App.CurrentApp?.ApplyFloatingStatsBehaviorSettings();
+ App.CurrentApp?.TrackClick("settings_floating_stats_font_size", new System.Collections.Generic.Dictionary
+ {
+ ["font_size"] = fontSize
+ });
+ }
+
private void MouseCalibration_Click(object sender, RoutedEventArgs e)
{
App.CurrentApp?.TrackClick("open_mouse_calibration");
@@ -238,4 +416,17 @@ private static void RestartApp()
}
Application.Current.Shutdown();
}
+
+ private sealed class FloatingMetricOption
+ {
+ public FloatingMetricOption(string id, string label)
+ {
+ Id = id;
+ Label = label;
+ }
+
+ public string Id { get; }
+
+ public string Label { get; }
+ }
}
diff --git a/KeyStats.Windows/design-qa.md b/KeyStats.Windows/design-qa.md
new file mode 100644
index 0000000..7079e41
--- /dev/null
+++ b/KeyStats.Windows/design-qa.md
@@ -0,0 +1,27 @@
+# Floating Stats Design QA
+
+## Production geometry
+
+- Layout scale baseline: WPF `FontSize="11"`.
+- Single-row baseline: 72 × 28 DIPs; at the `FontSize="12"` default it renders at 79 × 31 DIPs.
+- Double-row baseline: 32 × 38 DIPs; at the `FontSize="12"` default it renders at 35 × 41 DIPs.
+- The production XAML starts in the default double-row, `FontSize="12"`, 35 × 41 DIP state.
+- Other font sizes scale both window dimensions by `fontSize / 11` and round away from zero.
+
+## Static inspection
+
+- Both layouts use equal star-sized value regions with a dedicated separator region.
+- Values are centered, use character ellipsis when space is exhausted, and expose the full value in a tooltip.
+- Layout or font-size changes immediately re-clamp the window to the active monitor work area.
+- Monitor work areas are converted from device pixels with each monitor's own scale factor.
+- English, Simplified Chinese, and Traditional Chinese resources contain the same floating-stat keys.
+
+## Manual verification matrix
+
+The following checks remain required on Windows before release:
+
+- Render single-row and double-row layouts at 100%, 125%, and 150% display scaling.
+- Move the window between monitors with different scale factors and confirm that restore and edge clamping stay on the saved monitor.
+- Verify the translucent surface and text contrast in light and dark themes.
+- Exercise minimum and maximum font sizes with long distance values and confirm tooltip access.
+- Open Settings on a low-height display and confirm all controls remain reachable through vertical scrolling.