From 323b917d553613b2a38ffb3625b17fbd0d37f2f5 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 12:16:33 +0800 Subject: [PATCH 01/20] feat: add configurable floating and taskbar stats --- KeyStats.Windows/KeyStats/App.xaml.cs | 162 +++++++ .../KeyStats/Helpers/NativeInterop.cs | 75 ++++ .../KeyStats/Helpers/TaskbarStatsHost.cs | 405 +++++++++++++++++ .../KeyStats/Models/AppSettings.cs | 24 ++ .../KeyStats/Properties/Strings.cs | 16 + .../KeyStats/Properties/Strings.resx | 17 + .../KeyStats/Properties/Strings.zh-Hans.resx | 17 + .../KeyStats/Properties/Strings.zh-Hant.resx | 17 + .../KeyStats/Services/StatsManager.cs | 39 +- .../ViewModels/FloatingStatsViewModel.cs | 313 ++++++++++++++ .../KeyStats/Views/FloatingStatsWindow.xaml | 106 +++++ .../Views/FloatingStatsWindow.xaml.cs | 406 ++++++++++++++++++ .../KeyStats/Views/SettingsWindow.xaml | 21 + .../KeyStats/Views/SettingsWindow.xaml.cs | 34 ++ .../KeyStats/Views/TaskbarStatsView.xaml | 101 +++++ .../KeyStats/Views/TaskbarStatsView.xaml.cs | 152 +++++++ 16 files changed, 1904 insertions(+), 1 deletion(-) create mode 100644 KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs create mode 100644 KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs create mode 100644 KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml create mode 100644 KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs create mode 100644 KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml create mode 100644 KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml.cs diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index d456dd6..4fba2ed 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -29,6 +29,7 @@ public partial class App : System.Windows.Application private TrayIconViewModel? _trayIconViewModel; private TrayContextMenuHost? _trayContextMenuHost; private TaskbarCreatedWatcher? _taskbarCreatedWatcher; + private TaskbarStatsHost? _taskbarStatsHost; private SettingsWindow? _settingsWindow; private NotificationSettingsWindow? _notificationSettingsWindow; private MouseCalibrationWindow? _mouseCalibrationWindow; @@ -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 MenuItem? _taskbarStatsMenuItem; private System.Threading.Mutex? _singleInstanceMutex; private string? _appVersion; private IPostHogAnalytics? _postHogClient; private SyncCoordinator? _syncCoordinator; private long _lastResumeRecoveryTicks; + private bool _taskbarStatsPageviewTracked; protected override void OnStartup(StartupEventArgs e) { @@ -122,6 +127,7 @@ protected override void OnStartup(StartupEventArgs e) Console.WriteLine("Creating tray icon..."); _trayIconViewModel = new TrayIconViewModel(); _trayIconViewModel.PropertyChanged += OnTrayIconViewModelPropertyChanged; + _taskbarStatsHost = new TaskbarStatsHost(); _taskbarCreatedWatcher = new TaskbarCreatedWatcher(() => { Dispatcher.BeginInvoke(new Action(() => @@ -132,6 +138,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 +168,36 @@ 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); + + _taskbarStatsMenuItem = new System.Windows.Controls.MenuItem + { + Header = KeyStats.Properties.Strings.Tray_TaskbarStats, + IsCheckable = true, + IsChecked = StatsManager.Instance.Settings.TaskbarStatsEnabled + }; + _taskbarStatsMenuItem.Click += (s, e) => + { + var menuItem = (System.Windows.Controls.MenuItem)s!; + SetTaskbarStatsEnabled(menuItem.IsChecked, "tray_context_menu"); + }; + menu.Items.Add(_taskbarStatsMenuItem); + var settingsItem = new System.Windows.Controls.MenuItem { Header = KeyStats.Properties.Strings.Tray_Settings }; settingsItem.Click += (s, e) => { @@ -271,6 +312,55 @@ public void ShowStatsPanel() _trayIconViewModel?.ShowStatsCommand.Execute(null); } + public void ShowFloatingStatsWindow() + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(ShowFloatingStatsWindow)); + return; + } + + 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; + } + + _floatingStatsWindow?.Close(); + _floatingStatsWindow = null; + } + public void ShowMainWindow() { _trayIconViewModel?.ShowMainWindow(); @@ -477,6 +567,8 @@ protected override void OnExit(ExitEventArgs e) } TrackAnalyticsExit(); _trayIconViewModel?.Cleanup(); + _taskbarStatsHost?.Dispose(); + _taskbarStatsHost = null; _trayContextMenuHost?.Dispose(); _taskbarCreatedWatcher?.Dispose(); _taskbarCreatedWatcher = null; @@ -487,6 +579,8 @@ protected override void OnExit(ExitEventArgs e) _trayIcon.Dispose(); _trayIcon = null; } + _floatingStatsWindow?.Close(); + _floatingStatsWindow = null; InputMonitorService.Instance.StopMonitoring(); _syncCoordinator?.Dispose(); _syncCoordinator = null; @@ -897,6 +991,56 @@ public void TrackClick(string elementName, Dictionary? extraPro /// public static App? CurrentApp => Current as App; public SyncCoordinator? SyncCoordinator => _syncCoordinator; + public event Action? TaskbarStatsVisibilityChanged; + + public void SetTaskbarStatsEnabled(bool enabled, string source) + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(() => SetTaskbarStatsEnabled(enabled, source))); + return; + } + + var settings = StatsManager.Instance.Settings; + var changed = settings.TaskbarStatsEnabled != enabled; + settings.TaskbarStatsEnabled = enabled; + if (changed) + { + StatsManager.Instance.SaveSettings(); + } + + _taskbarStatsHost ??= new TaskbarStatsHost(); + _taskbarStatsHost.SetEnabled(enabled); + if (_taskbarStatsMenuItem != null) + { + _taskbarStatsMenuItem.IsChecked = enabled; + } + if (enabled) + { + TrackTaskbarStatsPageViewOnce(); + } + + if (changed) + { + TrackClick("taskbar_stats_visibility", new Dictionary + { + ["enabled"] = enabled, + ["source"] = source + }); + TaskbarStatsVisibilityChanged?.Invoke(enabled); + } + } + + private void TrackTaskbarStatsPageViewOnce() + { + if (_taskbarStatsPageviewTracked) + { + return; + } + + _taskbarStatsPageviewTracked = true; + TrackPageView("taskbar_stats"); + } private void RegisterSystemEventHandlers() { @@ -1024,6 +1168,24 @@ private void RecreateTrayIntegration() Visible = true }; _trayIcon.MouseClick += OnTrayIconMouseClick; + + var taskbarStatsEnabled = StatsManager.Instance.Settings.TaskbarStatsEnabled; + if (taskbarStatsEnabled) + { + if (_taskbarStatsHost?.IsEnabled == true) + { + _taskbarStatsHost.Recreate(); + } + else + { + _taskbarStatsHost?.SetEnabled(true); + } + TrackTaskbarStatsPageViewOnce(); + } + else + { + _taskbarStatsHost?.SetEnabled(false); + } } private void OnTrayIconMouseClick(object? sender, Forms.MouseEventArgs e) diff --git a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs index 1f28fe7..6b2e0ed 100644 --- a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs +++ b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs @@ -27,6 +27,24 @@ public static class NativeInterop public const int XBUTTON1 = 0x0001; // Back public const int XBUTTON2 = 0x0002; // Forward + public const int WS_CHILD = unchecked((int)0x40000000); + public const int WS_VISIBLE = 0x10000000; + public const int WS_CLIPSIBLINGS = 0x04000000; + public const int WS_CLIPCHILDREN = 0x02000000; + public const int WS_POPUP = unchecked((int)0x80000000); + + public const int WS_EX_TOOLWINDOW = 0x00000080; + public const int WS_EX_NOACTIVATE = 0x08000000; + public const int WS_EX_NOPARENTNOTIFY = 0x00000004; + + public const uint SWP_NOSIZE = 0x0001; + public const uint SWP_NOMOVE = 0x0002; + public const uint SWP_NOACTIVATE = 0x0010; + public const uint SWP_SHOWWINDOW = 0x0040; + + public static readonly IntPtr HWND_TOP = IntPtr.Zero; + public static readonly IntPtr HWND_TOPMOST = new(-1); + public const int VK_SHIFT = 0x10; public const int VK_CONTROL = 0x11; public const int VK_MENU = 0x12; // Alt key @@ -110,6 +128,45 @@ public struct MARGINS [return: MarshalAs(UnmanagedType.Bool)] public static extern bool DestroyIcon(IntPtr hIcon); + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr FindWindow(string? lpClassName, string? lpWindowName); + + [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr FindWindowEx( + IntPtr hWndParent, + IntPtr hWndChildAfter, + string? lpszClass, + string? lpszWindow); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern IntPtr GetParent(IntPtr hWnd); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetWindowPos( + IntPtr hWnd, + IntPtr hWndInsertAfter, + int x, + int y, + int cx, + int cy, + uint uFlags); + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(IntPtr hWnd); + [DllImport("shell32.dll", SetLastError = true)] public static extern int Shell_NotifyIconGetRect(ref NOTIFYICONIDENTIFIER identifier, out RECT iconLocation); @@ -131,6 +188,24 @@ public static bool IsKeyDown(int vkCode) return (GetAsyncKeyState(vkCode) & 0x8000) != 0; } + public static uint TryGetDpiForWindow(IntPtr hWnd) + { + if (hWnd == IntPtr.Zero) + { + return 96; + } + + try + { + var dpi = GetDpiForWindow(hWnd); + return dpi == 0 ? 96 : dpi; + } + catch (EntryPointNotFoundException) + { + return 96; + } + } + public static short HiWord(int dword) { return (short)(dword >> 16); diff --git a/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs b/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs new file mode 100644 index 0000000..fc2dc9a --- /dev/null +++ b/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs @@ -0,0 +1,405 @@ +using System; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; +using System.Windows.Threading; +using KeyStats.Views; + +namespace KeyStats.Helpers; + +/// +/// Hosts the compact statistics view as its own HWND beside the notification area. +/// +public sealed class TaskbarStatsHost : IDisposable +{ + private const int BaseWidth = 142; + private const int BaseHeight = 40; + private const int BaseEdgeInset = 2; + private const int BaseFallbackNotificationWidth = 96; + private const int WM_MOUSEACTIVATE = 0x0021; + private const int MA_NOACTIVATE = 3; + + private readonly DispatcherTimer _positionTimer; + private HwndSource? _source; + private TaskbarStatsView? _view; + private IntPtr _taskbarHandle; + private bool _isEmbedded; + private bool _enabled; + private bool _isDisposed; + + public bool IsEnabled => _enabled; + + public TaskbarStatsHost() + { + _positionTimer = new DispatcherTimer(DispatcherPriority.Background) + { + Interval = TimeSpan.FromSeconds(1) + }; + _positionTimer.Tick += OnPositionTimerTick; + ThemeManager.Instance.ThemeChanged += OnThemeChanged; + } + + public void SetEnabled(bool enabled) + { + if (_isDisposed || _enabled == enabled) + { + return; + } + + _enabled = enabled; + if (!enabled) + { + _positionTimer.Stop(); + DestroySource(); + return; + } + + EnsureHost(); + _positionTimer.Start(); + } + + public void Recreate() + { + if (_isDisposed || !_enabled) + { + return; + } + + DestroySource(); + EnsureHost(); + } + + public void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + _positionTimer.Stop(); + _positionTimer.Tick -= OnPositionTimerTick; + ThemeManager.Instance.ThemeChanged -= OnThemeChanged; + DestroySource(); + } + + private void OnPositionTimerTick(object? sender, EventArgs e) + { + EnsureHost(); + } + + private void OnThemeChanged() + { + if (_isDisposed) + { + return; + } + + Application.Current?.Dispatcher.BeginInvoke(new Action(() => + { + ApplyCompositionBackground(); + _view?.InvalidateVisual(); + })); + } + + private void EnsureHost() + { + if (!_enabled || _isDisposed) + { + return; + } + + var taskbar = NativeInterop.FindWindow("Shell_TrayWnd", null); + if (taskbar == IntPtr.Zero || !NativeInterop.IsWindow(taskbar)) + { + DestroySource(); + return; + } + + var sourceHandle = GetSourceHandle(); + var parentChanged = _taskbarHandle != IntPtr.Zero && _taskbarHandle != taskbar; + var embeddedParentChanged = _isEmbedded && + sourceHandle != IntPtr.Zero && + NativeInterop.GetParent(sourceHandle) != taskbar; + if (sourceHandle == IntPtr.Zero || parentChanged || embeddedParentChanged) + { + DestroySource(); + TryCreateHost(taskbar); + return; + } + + if (TryGetPlacement(taskbar, out var placement)) + { + ApplyPlacement(placement); + } + } + + private void TryCreateHost(IntPtr taskbar) + { + if (!TryGetPlacement(taskbar, out var placement)) + { + return; + } + + _taskbarHandle = taskbar; + try + { + CreateSource(placement, embedded: true); + return; + } + catch (Exception ex) + { + Console.WriteLine($"Taskbar stats embedding failed, using overlay fallback: {ex.Message}"); + DestroySource(); + _taskbarHandle = taskbar; + } + + try + { + CreateSource(placement, embedded: false); + } + catch (Exception ex) + { + Console.WriteLine($"Taskbar stats overlay fallback failed: {ex.Message}"); + DestroySource(); + } + } + + private void CreateSource(Placement placement, bool embedded) + { + var parameters = new HwndSourceParameters("KeyStatsTaskbarStatsWindow") + { + ParentWindow = embedded ? _taskbarHandle : IntPtr.Zero, + PositionX = embedded ? placement.RelativeX : placement.ScreenX, + PositionY = embedded ? placement.RelativeY : placement.ScreenY, + Width = placement.Width, + Height = placement.Height, + WindowStyle = embedded + ? NativeInterop.WS_CHILD | + NativeInterop.WS_VISIBLE | + NativeInterop.WS_CLIPSIBLINGS | + NativeInterop.WS_CLIPCHILDREN + : NativeInterop.WS_POPUP | NativeInterop.WS_VISIBLE, + ExtendedWindowStyle = NativeInterop.WS_EX_TOOLWINDOW | + NativeInterop.WS_EX_NOACTIVATE | + NativeInterop.WS_EX_NOPARENTNOTIFY + }; + + HwndSource? source = null; + TaskbarStatsView? view = null; + try + { + source = new HwndSource(parameters); + if (source.Handle == IntPtr.Zero) + { + throw new InvalidOperationException("The taskbar statistics HWND was not created."); + } + + view = new TaskbarStatsView(); + view.SetCompactMode(placement.Compact); + source.RootVisual = view; + source.AddHook(WindowHook); + + _source = source; + _view = view; + _isEmbedded = embedded; + ApplyCompositionBackground(); + ApplyPlacement(placement); + } + catch + { + view?.Cleanup(); + source?.Dispose(); + throw; + } + } + + private IntPtr WindowHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WM_MOUSEACTIVATE) + { + handled = true; + return new IntPtr(MA_NOACTIVATE); + } + + return IntPtr.Zero; + } + + private void ApplyPlacement(Placement placement) + { + var handle = GetSourceHandle(); + if (handle == IntPtr.Zero) + { + return; + } + + _view?.SetCompactMode(placement.Compact); + var x = _isEmbedded ? placement.RelativeX : placement.ScreenX; + var y = _isEmbedded ? placement.RelativeY : placement.ScreenY; + NativeInterop.SetWindowPos( + handle, + _isEmbedded ? NativeInterop.HWND_TOP : NativeInterop.HWND_TOPMOST, + x, + y, + placement.Width, + placement.Height, + NativeInterop.SWP_NOACTIVATE | NativeInterop.SWP_SHOWWINDOW); + } + + private void ApplyCompositionBackground() + { + if (_source?.CompositionTarget == null) + { + return; + } + + var color = Colors.Transparent; + if (Application.Current?.Resources["SurfaceColor"] is Color surfaceColor) + { + color = surfaceColor; + } + + _source.CompositionTarget.BackgroundColor = color; + } + + private static bool TryGetPlacement(IntPtr taskbar, out Placement placement) + { + placement = default; + if (!NativeInterop.GetWindowRect(taskbar, out var taskbarRect)) + { + return false; + } + + var taskbarWidth = taskbarRect.Right - taskbarRect.Left; + var taskbarHeight = taskbarRect.Bottom - taskbarRect.Top; + if (taskbarWidth <= 0 || taskbarHeight <= 0) + { + return false; + } + + var dpi = NativeInterop.TryGetDpiForWindow(taskbar); + var scale = dpi / 96.0; + var edgeInset = Math.Max(1, Scale(BaseEdgeInset, scale)); + var horizontal = taskbarWidth >= taskbarHeight; + var notify = NativeInterop.FindWindowEx(taskbar, IntPtr.Zero, "TrayNotifyWnd", null); + NativeInterop.RECT notifyRect = default; + var hasNotifyRect = notify != IntPtr.Zero && NativeInterop.GetWindowRect(notify, out notifyRect); + + int width; + int height; + int relativeX; + int relativeY; + bool compact; + + if (horizontal) + { + width = Math.Min(Scale(BaseWidth, scale), Math.Max(1, taskbarWidth - edgeInset * 2)); + height = Math.Min(Scale(BaseHeight, scale), Math.Max(1, taskbarHeight - edgeInset * 2)); + var notifyLeft = hasNotifyRect + ? notifyRect.Left - taskbarRect.Left + : taskbarWidth - Scale(BaseFallbackNotificationWidth, scale); + relativeX = Math.Max(edgeInset, notifyLeft - width - edgeInset); + relativeY = Math.Max(edgeInset, (taskbarHeight - height) / 2); + compact = false; + } + else + { + width = Math.Max(1, taskbarWidth - edgeInset * 2); + height = Math.Min(Scale(BaseHeight, scale), Math.Max(1, taskbarHeight - edgeInset * 2)); + var notifyTop = hasNotifyRect + ? notifyRect.Top - taskbarRect.Top + : taskbarHeight - Scale(BaseFallbackNotificationWidth, scale); + relativeX = edgeInset; + relativeY = Math.Max(edgeInset, notifyTop - height - edgeInset); + compact = true; + } + + placement = new Placement( + relativeX, + relativeY, + taskbarRect.Left + relativeX, + taskbarRect.Top + relativeY, + width, + height, + compact); + return true; + } + + private static int Scale(int value, double scale) + { + return Math.Max(1, (int)Math.Round(value * scale, MidpointRounding.AwayFromZero)); + } + + private IntPtr GetSourceHandle() + { + try + { + var handle = _source?.Handle ?? IntPtr.Zero; + return handle != IntPtr.Zero && NativeInterop.IsWindow(handle) ? handle : IntPtr.Zero; + } + catch (ObjectDisposedException) + { + return IntPtr.Zero; + } + } + + private void DestroySource() + { + _view?.Cleanup(); + _view = null; + + if (_source != null) + { + try + { + _source.RemoveHook(WindowHook); + } + catch (InvalidOperationException) + { + // Explorer may have already destroyed the child HWND. + } + + try + { + _source.Dispose(); + } + catch (ObjectDisposedException) + { + // The source was disposed as part of taskbar recreation. + } + _source = null; + } + + _taskbarHandle = IntPtr.Zero; + _isEmbedded = false; + } + + private readonly struct Placement + { + public Placement( + int relativeX, + int relativeY, + int screenX, + int screenY, + int width, + int height, + bool compact) + { + RelativeX = relativeX; + RelativeY = relativeY; + ScreenX = screenX; + ScreenY = screenY; + Width = width; + Height = height; + Compact = compact; + } + + public int RelativeX { get; } + public int RelativeY { get; } + public int ScreenX { get; } + public int ScreenY { get; } + public int Width { get; } + public int Height { get; } + public bool Compact { get; } + } +} diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index 37d45f6..becdd11 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -58,6 +58,30 @@ 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("floatingStatsLeft")] + public double? FloatingStatsLeft { get; set; } + + [JsonPropertyName("floatingStatsTop")] + public double? FloatingStatsTop { get; set; } + + [JsonPropertyName("floatingStatsTopmost")] + public bool FloatingStatsTopmost { get; set; } = true; + + [JsonPropertyName("floatingStatsPositionLocked")] + public bool FloatingStatsPositionLocked { get; set; } + + [JsonPropertyName("taskbarStatsEnabled")] + public bool TaskbarStatsEnabled { 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..e6f7551 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.cs +++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs @@ -29,6 +29,8 @@ 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_TaskbarStats => Get(nameof(Tray_TaskbarStats)); 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 +74,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_TaskbarStats => Get(nameof(Settings_TaskbarStats)); + public static string Settings_TaskbarStatsDesc => Get(nameof(Settings_TaskbarStatsDesc)); public static string Sync_WindowTitle => Get(nameof(Sync_WindowTitle)); public static string Sync_HeaderTitle => Get(nameof(Sync_HeaderTitle)); @@ -164,6 +168,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_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 TaskbarStats_OpenDetails => Get(nameof(TaskbarStats_OpenDetails)); + public static string TaskbarStats_PrimaryMetric => Get(nameof(TaskbarStats_PrimaryMetric)); + public static string TaskbarStats_SecondaryMetric => Get(nameof(TaskbarStats_SecondaryMetric)); + public static string TaskbarStats_Hide => Get(nameof(TaskbarStats_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..01c75d6 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx @@ -68,11 +68,28 @@ Today's clicks reached {0:N0}. Open Main Window + Show Today's Floating Stats + Show Taskbar Stats Settings Start at Login Key History Quit + Today's Stats - KeyStats + TODAY + First Metric + Second Metric + Always on Top + Lock Position + Open Detailed Stats + Hide Floating Window + Open Detailed Stats + First Row + Second Row + Hide Taskbar Stats + Taskbar Stats + Show today's two selected metrics as a separate two-row taskbar component. + 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..2c06f6b 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx @@ -68,11 +68,28 @@ 今日点击数已达到 {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..ec01962 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx @@ -68,11 +68,28 @@ 今日點擊數已達 {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..a156ed4 --- /dev/null +++ b/KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs @@ -0,0 +1,313 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows; +using KeyStats.Services; + +namespace KeyStats.ViewModels; + +public sealed class FloatingStatsViewModel : ViewModelBase +{ + 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; + } + + 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 void SetMetric(bool isPrimary, string metricId) + { + if (!IsValidMetric(metricId)) + { + return; + } + + var settings = StatsManager.Instance.Settings; + var otherMetric = isPrimary + ? settings.FloatingStatsSecondaryMetric + : settings.FloatingStatsPrimaryMetric; + if (string.Equals(metricId, otherMetric, StringComparison.Ordinal)) + { + return; + } + + if (isPrimary) + { + if (string.Equals(settings.FloatingStatsPrimaryMetric, metricId, StringComparison.Ordinal)) + { + return; + } + + settings.FloatingStatsPrimaryMetric = metricId; + } + else + { + if (string.Equals(settings.FloatingStatsSecondaryMetric, metricId, StringComparison.Ordinal)) + { + return; + } + + settings.FloatingStatsSecondaryMetric = metricId; + } + + StatsManager.Instance.SaveSettings(); + Refresh(); + } + + public void Cleanup() + { + if (_isCleanedUp) + { + return; + } + + _isCleanedUp = true; + StatsManager.Instance.StatsChanged -= OnStatsChanged; + } + + 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 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..a8c5676 --- /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..80986b4 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Threading; +using KeyStats.Helpers; +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 readonly FloatingStatsViewModel _viewModel; + private readonly DispatcherTimer _positionSaveTimer; + private bool _isLoaded; + private bool _isRestoringPosition; + private bool _isBackdropEnabled; + private ContextMenu? _metricSelectorMenu; + + 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(); + + SourceInitialized += OnSourceInitialized; + Loaded += OnLoaded; + Closed += OnClosed; + LocationChanged += OnLocationChanged; + ThemeManager.Instance.ThemeChanged += OnThemeChanged; + SystemEvents.DisplaySettingsChanged += OnDisplaySettingsChanged; + } + + public void ShowWindow() + { + if (!IsVisible) + { + Show(); + } + } + + private void OnSourceInitialized(object? sender, EventArgs e) + { + ApplyBackdrop(); + } + + 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 + }); + } + + 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(ApplyBackdrop)); + } + + private void OnDisplaySettingsChanged(object? sender, EventArgs e) + { + Dispatcher.BeginInvoke(new Action(EnsureVisiblePosition)); + } + + private void ApplyBackdrop() + { + _isBackdropEnabled = WindowBackdropHelper.Apply( + this, + NativeInterop.DwmSystemBackdropType.TransientWindow); + RootBorder.SetResourceReference( + Border.BackgroundProperty, + _isBackdropEnabled ? "TrayBackdropTintBrush" : "SurfaceBrush"); + 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. + } + } + + private ContextMenu BuildContextMenu() + { + var menu = new ContextMenu(); + + var primaryMetricItem = new MenuItem + { + Header = $"{KeyStats.Properties.Strings.FloatingStats_PrimaryMetric}: {_viewModel.PrimaryLabel}" + }; + primaryMetricItem.Click += (_, _) => OpenMetricSelector(isPrimary: true); + menu.Items.Add(primaryMetricItem); + + var secondaryMetricItem = new MenuItem + { + Header = $"{KeyStats.Properties.Strings.FloatingStats_SecondaryMetric}: {_viewModel.SecondaryLabel}" + }; + secondaryMetricItem.Click += (_, _) => OpenMetricSelector(isPrimary: false); + menu.Items.Add(secondaryMetricItem); + menu.Items.Add(new Separator()); + + var topmostItem = new MenuItem + { + Header = KeyStats.Properties.Strings.FloatingStats_AlwaysOnTop, + IsCheckable = true, + IsChecked = StatsManager.Instance.Settings.FloatingStatsTopmost + }; + topmostItem.Click += (_, _) => + { + var enabled = topmostItem.IsChecked; + Topmost = enabled; + StatsManager.Instance.Settings.FloatingStatsTopmost = enabled; + StatsManager.Instance.SaveSettings(); + App.CurrentApp?.TrackClick("floating_stats_topmost", new Dictionary + { + ["enabled"] = enabled + }); + }; + menu.Items.Add(topmostItem); + + var lockItem = new MenuItem + { + Header = KeyStats.Properties.Strings.FloatingStats_LockPosition, + IsCheckable = true, + IsChecked = StatsManager.Instance.Settings.FloatingStatsPositionLocked + }; + lockItem.Click += (_, _) => + { + var enabled = lockItem.IsChecked; + StatsManager.Instance.Settings.FloatingStatsPositionLocked = enabled; + StatsManager.Instance.SaveSettings(); + UpdateDragCursor(); + App.CurrentApp?.TrackClick("floating_stats_position_lock", new Dictionary + { + ["enabled"] = enabled + }); + }; + menu.Items.Add(lockItem); + menu.Items.Add(new Separator()); + + var openDetailsItem = new MenuItem + { + Header = KeyStats.Properties.Strings.FloatingStats_OpenDetails + }; + openDetailsItem.Click += (_, _) => + { + App.CurrentApp?.TrackClick("floating_stats_open_details"); + App.CurrentApp?.ShowMainWindow(); + }; + menu.Items.Add(openDetailsItem); + + var hideItem = new MenuItem + { + Header = KeyStats.Properties.Strings.FloatingStats_Hide + }; + hideItem.Click += (_, _) => + { + App.CurrentApp?.TrackClick("floating_stats_hide"); + Dispatcher.BeginInvoke(new Action(() => App.CurrentApp?.SetFloatingStatsVisible(false))); + }; + menu.Items.Add(hideItem); + + return menu; + } + + private void OpenMetricSelector(bool isPrimary) + { + if (_metricSelectorMenu != null) + { + _metricSelectorMenu.IsOpen = false; + } + var selector = new ContextMenu + { + PlacementTarget = RootBorder, + Placement = PlacementMode.MousePoint + }; + _metricSelectorMenu = selector; + selector.Closed += (_, _) => + { + if (ReferenceEquals(_metricSelectorMenu, selector)) + { + _metricSelectorMenu = null; + } + }; + + var selectedMetric = isPrimary ? _viewModel.PrimaryMetricId : _viewModel.SecondaryMetricId; + var otherMetric = isPrimary ? _viewModel.SecondaryMetricId : _viewModel.PrimaryMetricId; + + foreach (var metricId in FloatingStatsViewModel.AvailableMetricIds) + { + var capturedMetricId = metricId; + var item = new MenuItem + { + Header = FloatingStatsViewModel.GetMetricLabel(metricId), + IsCheckable = true, + IsChecked = string.Equals(metricId, selectedMetric, StringComparison.Ordinal), + IsEnabled = !string.Equals(metricId, otherMetric, StringComparison.Ordinal) + }; + item.Click += (_, _) => + { + _viewModel.SetMetric(isPrimary, capturedMetricId); + RootBorder.ContextMenu = BuildContextMenu(); + App.CurrentApp?.TrackClick("floating_stats_metric_change", new Dictionary + { + ["slot"] = isPrimary ? "primary" : "secondary", + ["metric"] = capturedMetricId + }); + }; + selector.Items.Add(item); + } + + Dispatcher.BeginInvoke(new Action(() => selector.IsOpen = true)); + } + + private void UpdateDragCursor() + { + RootBorder.Cursor = StatsManager.Instance.Settings.FloatingStatsPositionLocked + ? Cursors.Arrow + : Cursors.SizeAll; + } + + private void OnLocationChanged(object? sender, EventArgs e) + { + if (!_isLoaded || _isRestoringPosition) + { + return; + } + + _positionSaveTimer.Stop(); + _positionSaveTimer.Start(); + } + + private void PositionSaveTimer_Tick(object? sender, EventArgs e) + { + _positionSaveTimer.Stop(); + var settings = StatsManager.Instance.Settings; + settings.FloatingStatsLeft = Left; + settings.FloatingStatsTop = Top; + StatsManager.Instance.SaveSettings(); + } + + private void RestorePosition() + { + var workingAreas = GetWorkingAreasInDips(); + var preferredArea = workingAreas.Count > 0 + ? workingAreas[0] + : SystemParameters.WorkArea; + var settings = StatsManager.Instance.Settings; + var requestedBounds = settings.FloatingStatsLeft.HasValue && settings.FloatingStatsTop.HasValue + ? new Rect(settings.FloatingStatsLeft.Value, settings.FloatingStatsTop.Value, Width, Height) + : new Rect( + preferredArea.Right - Width - EdgeMargin, + preferredArea.Top + EdgeMargin, + Width, + Height); + + var targetArea = FindBestWorkingArea(requestedBounds, workingAreas) ?? preferredArea; + var clamped = ClampToArea(requestedBounds, targetArea); + + _isRestoringPosition = true; + try + { + Left = clamped.Left; + Top = clamped.Top; + } + finally + { + _isRestoringPosition = false; + } + } + + private void EnsureVisiblePosition() + { + if (!_isLoaded) + { + return; + } + + var workingAreas = GetWorkingAreasInDips(); + var preferredArea = workingAreas.Count > 0 + ? workingAreas[0] + : SystemParameters.WorkArea; + var bounds = new Rect(Left, Top, Width, Height); + var targetArea = FindBestWorkingArea(bounds, workingAreas) ?? preferredArea; + var clamped = ClampToArea(bounds, targetArea); + + _isRestoringPosition = true; + try + { + Left = clamped.Left; + Top = clamped.Top; + } + finally + { + _isRestoringPosition = false; + } + + var settings = StatsManager.Instance.Settings; + settings.FloatingStatsLeft = Left; + settings.FloatingStatsTop = Top; + StatsManager.Instance.SaveSettings(); + } + + private List GetWorkingAreasInDips() + { + var areas = new List(); + var source = PresentationSource.FromVisual(this); + var fromDevice = source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity; + + foreach (var screen in Forms.Screen.AllScreens) + { + var topLeft = fromDevice.Transform(new Point(screen.WorkingArea.Left, screen.WorkingArea.Top)); + var bottomRight = fromDevice.Transform(new Point(screen.WorkingArea.Right, screen.WorkingArea.Bottom)); + var area = new Rect(topLeft, bottomRight); + if (screen.Primary) + { + areas.Insert(0, area); + } + else + { + areas.Add(area); + } + } + + return areas; + } + + private static Rect? FindBestWorkingArea(Rect bounds, IReadOnlyList workingAreas) + { + Rect? bestArea = null; + var bestIntersection = 0.0; + foreach (var area in workingAreas) + { + var intersection = Rect.Intersect(bounds, area); + 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); + } +} diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml index b9e636e..17ae3a4 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml @@ -99,6 +99,27 @@ + + + + + + + + + + + + + + diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs index 5b38d7e..e4df546 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs @@ -11,6 +11,7 @@ namespace KeyStats.Views; public partial class SettingsWindow : Window { private const string GitHubUrl = "https://github.com/debugtheworldbot/keyStats"; + private bool _isLoadingTaskbarStats = true; public SettingsWindow() { @@ -24,6 +25,13 @@ public SettingsWindow() private void OnLoaded(object sender, RoutedEventArgs e) { ApplyWindowBackdrop(); + _isLoadingTaskbarStats = true; + TaskbarStatsCheckBox.IsChecked = StatsManager.Instance.Settings.TaskbarStatsEnabled; + _isLoadingTaskbarStats = false; + if (App.CurrentApp != null) + { + App.CurrentApp.TaskbarStatsVisibilityChanged += OnTaskbarStatsVisibilityChanged; + } if (App.CurrentApp?.SyncCoordinator != null) { App.CurrentApp.SyncCoordinator.StatusChanged += OnSyncStatusChanged; @@ -35,6 +43,10 @@ private void OnLoaded(object sender, RoutedEventArgs e) private void OnClosed(object? sender, System.EventArgs e) { ThemeManager.Instance.ThemeChanged -= OnThemeChanged; + if (App.CurrentApp != null) + { + App.CurrentApp.TaskbarStatsVisibilityChanged -= OnTaskbarStatsVisibilityChanged; + } if (App.CurrentApp?.SyncCoordinator != null) { App.CurrentApp.SyncCoordinator.StatusChanged -= OnSyncStatusChanged; @@ -145,6 +157,28 @@ private void NotificationSettings_Click(object sender, RoutedEventArgs e) App.CurrentApp?.ShowNotificationSettings(); } + private void TaskbarStatsVisibility_Changed(object sender, RoutedEventArgs e) + { + if (_isLoadingTaskbarStats) + { + return; + } + + App.CurrentApp?.SetTaskbarStatsEnabled( + TaskbarStatsCheckBox.IsChecked == true, + "settings"); + } + + private void OnTaskbarStatsVisibilityChanged(bool enabled) + { + Dispatcher.BeginInvoke(new System.Action(() => + { + _isLoadingTaskbarStats = true; + TaskbarStatsCheckBox.IsChecked = enabled; + _isLoadingTaskbarStats = false; + })); + } + private void MouseCalibration_Click(object sender, RoutedEventArgs e) { App.CurrentApp?.TrackClick("open_mouse_calibration"); diff --git a/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml b/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml new file mode 100644 index 0000000..db4d2f0 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml.cs b/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml.cs new file mode 100644 index 0000000..a4feae3 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using KeyStats.ViewModels; + +namespace KeyStats.Views; + +public partial class TaskbarStatsView : UserControl +{ + private readonly FloatingStatsViewModel _viewModel; + private readonly Dictionary _primaryMetricItems = new(StringComparer.Ordinal); + private readonly Dictionary _secondaryMetricItems = new(StringComparer.Ordinal); + private bool _isCleanedUp; + + public TaskbarStatsView() + { + InitializeComponent(); + _viewModel = new FloatingStatsViewModel(); + DataContext = _viewModel; + RootBorder.ContextMenu = CreateContextMenu(); + } + + public void Cleanup() + { + if (_isCleanedUp) + { + return; + } + + _isCleanedUp = true; + RootBorder.ContextMenu = null; + _viewModel.Cleanup(); + } + + public void SetCompactMode(bool compact) + { + var visibility = compact ? Visibility.Collapsed : Visibility.Visible; + PrimaryLabelBlock.Visibility = visibility; + SecondaryLabelBlock.Visibility = visibility; + RootBorder.Padding = compact ? new Thickness(3, 2, 3, 2) : new Thickness(6, 2, 6, 2); + } + + private ContextMenu CreateContextMenu() + { + var menu = new ContextMenu(); + menu.Opened += (_, _) => RefreshMetricMenuState(); + + var openDetailsItem = new MenuItem + { + Header = KeyStats.Properties.Strings.TaskbarStats_OpenDetails + }; + openDetailsItem.Click += (_, _) => OpenDetails(); + menu.Items.Add(openDetailsItem); + menu.Items.Add(new Separator()); + + var primaryItem = new MenuItem + { + Header = KeyStats.Properties.Strings.TaskbarStats_PrimaryMetric + }; + PopulateMetricMenu(primaryItem, isPrimary: true, _primaryMetricItems); + menu.Items.Add(primaryItem); + + var secondaryItem = new MenuItem + { + Header = KeyStats.Properties.Strings.TaskbarStats_SecondaryMetric + }; + PopulateMetricMenu(secondaryItem, isPrimary: false, _secondaryMetricItems); + menu.Items.Add(secondaryItem); + menu.Items.Add(new Separator()); + + var hideItem = new MenuItem + { + Header = KeyStats.Properties.Strings.TaskbarStats_Hide + }; + hideItem.Click += (_, _) => Dispatcher.BeginInvoke(new Action(() => + App.CurrentApp?.SetTaskbarStatsEnabled(false, "taskbar_stats_context_menu"))); + menu.Items.Add(hideItem); + + return menu; + } + + private void PopulateMetricMenu( + ItemsControl parent, + bool isPrimary, + IDictionary destination) + { + foreach (var metricId in FloatingStatsViewModel.AvailableMetricIds) + { + var capturedMetricId = metricId; + var item = new MenuItem + { + Header = FloatingStatsViewModel.GetMetricLabel(metricId), + IsCheckable = true, + StaysOpenOnClick = false + }; + item.Click += (_, _) => SelectMetric(isPrimary, capturedMetricId); + destination[metricId] = item; + parent.Items.Add(item); + } + } + + private void RefreshMetricMenuState() + { + var primaryMetric = _viewModel.PrimaryMetricId; + var secondaryMetric = _viewModel.SecondaryMetricId; + + foreach (var pair in _primaryMetricItems) + { + pair.Value.IsChecked = string.Equals(pair.Key, primaryMetric, StringComparison.Ordinal); + pair.Value.IsEnabled = !string.Equals(pair.Key, secondaryMetric, StringComparison.Ordinal); + } + + foreach (var pair in _secondaryMetricItems) + { + pair.Value.IsChecked = string.Equals(pair.Key, secondaryMetric, StringComparison.Ordinal); + pair.Value.IsEnabled = !string.Equals(pair.Key, primaryMetric, StringComparison.Ordinal); + } + } + + private void SelectMetric(bool isPrimary, string metricId) + { + var currentMetric = isPrimary ? _viewModel.PrimaryMetricId : _viewModel.SecondaryMetricId; + if (string.Equals(currentMetric, metricId, StringComparison.Ordinal)) + { + return; + } + + _viewModel.SetMetric(isPrimary, metricId); + App.CurrentApp?.TrackClick("taskbar_stats_metric_change", new Dictionary + { + ["row"] = isPrimary ? "primary" : "secondary", + ["metric"] = metricId + }); + } + + private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + if (e.ChangedButton == MouseButton.Left && e.ClickCount == 2) + { + OpenDetails(); + e.Handled = true; + } + } + + private static void OpenDetails() + { + App.CurrentApp?.TrackClick("taskbar_stats_open_details"); + App.CurrentApp?.ShowMainWindow(); + } +} From f9510da52891b848c39d5dfb7f73bb0fee004c56 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 12:19:23 +0800 Subject: [PATCH 02/20] test: document floating stats design qa --- KeyStats.Windows/design-qa.md | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 KeyStats.Windows/design-qa.md diff --git a/KeyStats.Windows/design-qa.md b/KeyStats.Windows/design-qa.md new file mode 100644 index 0000000..590d893 --- /dev/null +++ b/KeyStats.Windows/design-qa.md @@ -0,0 +1,44 @@ +# Floating Stats Design QA + +- Source visual truth: `C:\Users\t\AppData\Local\Temp\browser-use\assets\5c950f33-06aa-4851-b388-883f9b9d6750\d0011dafe24be0ae.png` +- Rendered implementation: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-implementation.png` +- Combined comparison: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-comparison.png` +- Viewport: KeyStats floating surface at 248 × 84 device-independent pixels, rendered at 96 DPI / 1× density +- Pixel dimensions: source 304 × 140; implementation 248 × 84; comparison canvas 580 × 128 +- State: Windows light theme, today's key presses and total mouse clicks, representative non-zero values +- Normalization: the 278 × 54 TrafficMonitor floating-window region was cropped without density scaling; the KeyStats surface was rendered at its production XAML size. The source tooltip was excluded because it is a transient secondary state. + +## Full-view comparison evidence + +The combined comparison confirms the shared TrafficMonitor pattern: a compact always-available surface, two horizontally grouped metrics, small descriptive labels, prominent live values, and a thin visual boundary. KeyStats intentionally maps the pattern to its existing translucent Windows materials, Segoe typography, accent icons, rounded corners, and theme resources instead of copying TrafficMonitor's green skin. + +## Required fidelity surfaces + +- Fonts and typography: Segoe UI/Segoe MDL2 render clearly at the production 10 px label and 22 px value sizes. Hierarchy and truncation behavior are appropriate for compact counts. +- Spacing and layout rhythm: both metrics have equal width, a centered divider, consistent 11 px side padding, and sufficient vertical room for the explicit “Today” context. +- Colors and visual tokens: the surface uses KeyStats dynamic accent, text, divider, backdrop tint, and popup border resources. Contrast is clear in the rendered light state; dark-mode values are supplied through the existing ThemeManager tokens. +- Image quality and asset fidelity: the component has no raster imagery. Keyboard and mouse marks use the platform Segoe MDL2 icon font rather than approximate custom artwork. +- Copy and content: “Today,” “Key Presses,” and “Mouse Clicks” accurately describe the default aggregate-only metrics. Localized English, Simplified Chinese, and Traditional Chinese resources are present. + +## Focused-region comparison evidence + +No separate focused crop was necessary because labels, values, icons, border, divider, and corner treatment are all legible at original 1× resolution in the combined comparison. + +## Findings + +- No actionable P0, P1, or P2 differences. +- P3: KeyStats is 30 px taller than the cropped TrafficMonitor reference. This is an intentional readability tradeoff for larger numeric values and an explicit “Today” label. +- P3: KeyStats uses neutral translucent materials rather than TrafficMonitor green. This preserves established product theming and dark-mode behavior. + +## Comparison history + +- Pass 1: no P0/P1/P2 findings; no visual fix iteration was required. + +## Implementation checklist + +- Production XAML rendered from the exact floating-window source file. +- Debug build completed with 0 warnings and 0 errors. +- Resource XML and localization-key parity validated. +- Move, position persistence, metric selection, topmost, position lock, details, and hide paths are implemented. + +final result: passed From 6e9e442ca7fbe9989cafde5d90775461bc7cb2f1 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 12:35:38 +0800 Subject: [PATCH 03/20] refactor: simplify floating stats display --- .../KeyStats/Views/FloatingStatsWindow.xaml | 112 ++++++------------ KeyStats.Windows/design-qa.md | 34 +++--- 2 files changed, 51 insertions(+), 95 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index a8c5676..eec4fd9 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -3,8 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:p="clr-namespace:KeyStats.Properties" Title="{x:Static p:Strings.FloatingStats_WindowTitle}" - Width="248" - Height="84" + Width="184" + Height="56" WindowStyle="None" AllowsTransparency="False" Background="Transparent" @@ -20,87 +20,41 @@ CornerRadius="9" Cursor="SizeAll" MouseLeftButtonDown="RootBorder_MouseLeftButtonDown"> - - - - - + + + + + + - + Foreground="{DynamicResource TextPrimaryBrush}" + TextAlignment="Center" + TextTrimming="CharacterEllipsis" + HorizontalAlignment="Stretch" + VerticalAlignment="Center" + ToolTip="{Binding PrimaryFullValue}"/> - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/KeyStats.Windows/design-qa.md b/KeyStats.Windows/design-qa.md index 590d893..ae93811 100644 --- a/KeyStats.Windows/design-qa.md +++ b/KeyStats.Windows/design-qa.md @@ -1,44 +1,46 @@ # Floating Stats Design QA - Source visual truth: `C:\Users\t\AppData\Local\Temp\browser-use\assets\5c950f33-06aa-4851-b388-883f9b9d6750\d0011dafe24be0ae.png` -- Rendered implementation: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-implementation.png` -- Combined comparison: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-comparison.png` -- Viewport: KeyStats floating surface at 248 × 84 device-independent pixels, rendered at 96 DPI / 1× density -- Pixel dimensions: source 304 × 140; implementation 248 × 84; comparison canvas 580 × 128 +- Rendered implementation: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-implementation-minimal.png` +- Combined comparison: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-comparison-minimal.png` +- Viewport: KeyStats floating surface at 184 × 56 device-independent pixels, rendered at 96 DPI / 1× density +- Pixel dimensions: source 304 × 140; implementation 184 × 56; comparison canvas 510 × 98 - State: Windows light theme, today's key presses and total mouse clicks, representative non-zero values - Normalization: the 278 × 54 TrafficMonitor floating-window region was cropped without density scaling; the KeyStats surface was rendered at its production XAML size. The source tooltip was excluded because it is a transient secondary state. ## Full-view comparison evidence -The combined comparison confirms the shared TrafficMonitor pattern: a compact always-available surface, two horizontally grouped metrics, small descriptive labels, prominent live values, and a thin visual boundary. KeyStats intentionally maps the pattern to its existing translucent Windows materials, Segoe typography, accent icons, rounded corners, and theme resources instead of copying TrafficMonitor's green skin. +The comparison confirms a compact, bordered, two-column readout with strong numeric hierarchy and a centered divider. The 56 px KeyStats height now closely matches TrafficMonitor's 54 px reference height. The narrower KeyStats width is intentional because the requested surface contains two values rather than TrafficMonitor's four labeled metrics. Visible labels and icons are intentionally omitted by the user's latest direction; metric identity remains available through hover tooltips and the right-click configuration menu. ## Required fidelity surfaces -- Fonts and typography: Segoe UI/Segoe MDL2 render clearly at the production 10 px label and 22 px value sizes. Hierarchy and truncation behavior are appropriate for compact counts. -- Spacing and layout rhythm: both metrics have equal width, a centered divider, consistent 11 px side padding, and sufficient vertical room for the explicit “Today” context. -- Colors and visual tokens: the surface uses KeyStats dynamic accent, text, divider, backdrop tint, and popup border resources. Contrast is clear in the rendered light state; dark-mode values are supplied through the existing ThemeManager tokens. -- Image quality and asset fidelity: the component has no raster imagery. Keyboard and mouse marks use the platform Segoe MDL2 icon font rather than approximate custom artwork. -- Copy and content: “Today,” “Key Presses,” and “Mouse Clicks” accurately describe the default aggregate-only metrics. Localized English, Simplified Chinese, and Traditional Chinese resources are present. +- Fonts and typography: both values use Segoe UI at 22 px semibold with consistent baseline, centering, antialiasing, and ellipsis behavior. There is no secondary text hierarchy by design. +- Spacing and layout rhythm: the two equal-width tracks use 9 px side padding, 6 px vertical padding, a 13 px divider gutter, and a centered 28 px hairline. The 184 × 56 frame is compact without crowding the values. +- Colors and visual tokens: the surface retains KeyStats dynamic primary text, divider, translucent backdrop tint, and popup border resources. The light-state capture has clear contrast; dark mode continues through ThemeManager. +- Image quality and asset fidelity: there are no visible raster images, icons, or decorative assets in the revised component. +- Copy and content: only the two requested statistic values are visible. Metric names and exact expanded values remain accessible in native tooltips and menus rather than permanent chrome. ## Focused-region comparison evidence -No separate focused crop was necessary because labels, values, icons, border, divider, and corner treatment are all legible at original 1× resolution in the combined comparison. +No separate focused crop was required because both numbers, the divider, border, radius, padding, and alignment are legible at original 1× resolution in the combined comparison. ## Findings - No actionable P0, P1, or P2 differences. -- P3: KeyStats is 30 px taller than the cropped TrafficMonitor reference. This is an intentional readability tradeoff for larger numeric values and an explicit “Today” label. -- P3: KeyStats uses neutral translucent materials rather than TrafficMonitor green. This preserves established product theming and dark-mode behavior. +- Accepted intentional difference: TrafficMonitor shows labels and four values, while KeyStats follows the user's explicit two-number-only requirement. +- Accepted intentional difference: KeyStats keeps its neutral translucent theme instead of copying TrafficMonitor's green skin. ## Comparison history -- Pass 1: no P0/P1/P2 findings; no visual fix iteration was required. +- Earlier implementation: 248 × 84 with “Today,” icons, labels, and values. +- User-directed revision: removed all visible labels and icons, reduced the frame to 184 × 56, and centered the two values. +- Post-fix evidence: the latest combined comparison shows no remaining P0/P1/P2 issue. ## Implementation checklist - Production XAML rendered from the exact floating-window source file. - Debug build completed with 0 warnings and 0 errors. -- Resource XML and localization-key parity validated. -- Move, position persistence, metric selection, topmost, position lock, details, and hide paths are implemented. +- Two-value hover tooltips preserve metric identification and exact values. +- Move, position persistence, metric selection, topmost, position lock, details, and hide paths remain intact. final result: passed From d3ff500a458841383c62e85ff2d0df9df7c33187 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 12:39:38 +0800 Subject: [PATCH 04/20] refactor: tighten floating stats layout --- .../KeyStats/Views/FloatingStatsWindow.xaml | 21 +++++++++++-------- KeyStats.Windows/design-qa.md | 18 +++++++++------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index eec4fd9..802ec84 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -3,8 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:p="clr-namespace:KeyStats.Properties" Title="{x:Static p:Strings.FloatingStats_WindowTitle}" - Width="184" - Height="56" + Width="136" + Height="36" WindowStyle="None" AllowsTransparency="False" Background="Transparent" @@ -12,24 +12,27 @@ ShowActivated="False" Topmost="True" ResizeMode="NoResize" - WindowStartupLocation="Manual"> + WindowStartupLocation="Manual" + TextOptions.TextFormattingMode="Display" + TextOptions.TextRenderingMode="ClearType"> - + - + Date: Sun, 23 Aug 2026 12:50:40 +0800 Subject: [PATCH 05/20] refactor: move floating stats options to settings --- KeyStats.Windows/KeyStats/App.xaml.cs | 11 ++ .../KeyStats/Properties/Strings.cs | 2 + .../KeyStats/Properties/Strings.resx | 2 + .../KeyStats/Properties/Strings.zh-Hans.resx | 2 + .../KeyStats/Properties/Strings.zh-Hant.resx | 2 + .../ViewModels/FloatingStatsViewModel.cs | 34 ++++- .../Views/FloatingStatsWindow.xaml.cs | 136 ++---------------- .../KeyStats/Views/SettingsWindow.xaml | 59 ++++++++ .../KeyStats/Views/SettingsWindow.xaml.cs | 90 ++++++++++++ 9 files changed, 209 insertions(+), 129 deletions(-) diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index 4fba2ed..a71fc59 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -361,6 +361,17 @@ public void SetFloatingStatsVisible(bool isVisible) _floatingStatsWindow = null; } + public void ApplyFloatingStatsBehaviorSettings() + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.BeginInvoke(new Action(ApplyFloatingStatsBehaviorSettings)); + return; + } + + _floatingStatsWindow?.ApplyBehaviorSettings(); + } + public void ShowMainWindow() { _trayIconViewModel?.ShowMainWindow(); diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.cs b/KeyStats.Windows/KeyStats/Properties/Strings.cs index e6f7551..0ddb0b1 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.cs +++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs @@ -74,6 +74,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 Settings_TaskbarStats => Get(nameof(Settings_TaskbarStats)); public static string Settings_TaskbarStatsDesc => Get(nameof(Settings_TaskbarStatsDesc)); diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.resx b/KeyStats.Windows/KeyStats/Properties/Strings.resx index 01c75d6..4ed17d8 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx @@ -89,6 +89,8 @@ Hide Taskbar Stats Taskbar Stats Show today's two selected metrics as a separate two-row taskbar component. + Floating Stats + Choose the two metrics shown in the desktop floating window and adjust its behavior. Export Successful Data saved to {0} diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx index 2c06f6b..f1e0df3 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx @@ -89,6 +89,8 @@ 隐藏任务栏统计 任务栏统计 在任务栏中以独立双排组件显示今日选中的两项统计。 + 统计浮窗 + 选择桌面浮窗显示的两项统计,并调整浮窗行为。 导出成功 数据已保存到 {0} diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx index ec01962..0b8820b 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx @@ -89,6 +89,8 @@ 隱藏工作列統計 工作列統計 在工作列中以獨立雙排元件顯示今日選取的兩項統計。 + 統計浮窗 + 選擇桌面浮窗顯示的兩項統計,並調整浮窗行為。 匯出成功 資料已儲存至 {0} diff --git a/KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs b/KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs index a156ed4..4acc822 100644 --- a/KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs +++ b/KeyStats.Windows/KeyStats/ViewModels/FloatingStatsViewModel.cs @@ -8,6 +8,8 @@ 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"; @@ -46,6 +48,7 @@ public FloatingStatsViewModel() NormalizeMetricSettings(); Refresh(); StatsManager.Instance.StatsChanged += OnStatsChanged; + MetricSettingsChanged += OnMetricSettingsChanged; } public static IReadOnlyList AvailableMetricIds => MetricIds; @@ -137,11 +140,16 @@ public static string GetMetricLabel(string metricId) }; } - public void SetMetric(bool isPrimary, string metricId) + public bool SetMetric(bool isPrimary, string metricId) + { + return UpdateMetricSetting(isPrimary, metricId); + } + + public static bool UpdateMetricSetting(bool isPrimary, string metricId) { if (!IsValidMetric(metricId)) { - return; + return false; } var settings = StatsManager.Instance.Settings; @@ -150,14 +158,14 @@ public void SetMetric(bool isPrimary, string metricId) : settings.FloatingStatsPrimaryMetric; if (string.Equals(metricId, otherMetric, StringComparison.Ordinal)) { - return; + return false; } if (isPrimary) { if (string.Equals(settings.FloatingStatsPrimaryMetric, metricId, StringComparison.Ordinal)) { - return; + return false; } settings.FloatingStatsPrimaryMetric = metricId; @@ -166,14 +174,15 @@ public void SetMetric(bool isPrimary, string metricId) { if (string.Equals(settings.FloatingStatsSecondaryMetric, metricId, StringComparison.Ordinal)) { - return; + return false; } settings.FloatingStatsSecondaryMetric = metricId; } StatsManager.Instance.SaveSettings(); - Refresh(); + MetricSettingsChanged?.Invoke(); + return true; } public void Cleanup() @@ -185,6 +194,7 @@ public void Cleanup() _isCleanedUp = true; StatsManager.Instance.StatsChanged -= OnStatsChanged; + MetricSettingsChanged -= OnMetricSettingsChanged; } private void NormalizeMetricSettings() @@ -234,6 +244,18 @@ private void OnStatsChanged(StatsManager.StatsUpdateKind _) 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) diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs index 80986b4..27e12ee 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Windows; using System.Windows.Controls; -using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Threading; @@ -22,7 +21,6 @@ public partial class FloatingStatsWindow : Window private bool _isLoaded; private bool _isRestoringPosition; private bool _isBackdropEnabled; - private ContextMenu? _metricSelectorMenu; public FloatingStatsWindow() { @@ -55,6 +53,13 @@ public void ShowWindow() } } + public void ApplyBehaviorSettings() + { + var settings = StatsManager.Instance.Settings; + Topmost = settings.FloatingStatsTopmost; + UpdateDragCursor(); + } + private void OnSourceInitialized(object? sender, EventArgs e) { ApplyBackdrop(); @@ -135,135 +140,20 @@ private void RootBorder_MouseLeftButtonDown(object sender, MouseButtonEventArgs private ContextMenu BuildContextMenu() { var menu = new ContextMenu(); - - var primaryMetricItem = new MenuItem - { - Header = $"{KeyStats.Properties.Strings.FloatingStats_PrimaryMetric}: {_viewModel.PrimaryLabel}" - }; - primaryMetricItem.Click += (_, _) => OpenMetricSelector(isPrimary: true); - menu.Items.Add(primaryMetricItem); - - var secondaryMetricItem = new MenuItem - { - Header = $"{KeyStats.Properties.Strings.FloatingStats_SecondaryMetric}: {_viewModel.SecondaryLabel}" - }; - secondaryMetricItem.Click += (_, _) => OpenMetricSelector(isPrimary: false); - menu.Items.Add(secondaryMetricItem); - menu.Items.Add(new Separator()); - - var topmostItem = new MenuItem - { - Header = KeyStats.Properties.Strings.FloatingStats_AlwaysOnTop, - IsCheckable = true, - IsChecked = StatsManager.Instance.Settings.FloatingStatsTopmost - }; - topmostItem.Click += (_, _) => - { - var enabled = topmostItem.IsChecked; - Topmost = enabled; - StatsManager.Instance.Settings.FloatingStatsTopmost = enabled; - StatsManager.Instance.SaveSettings(); - App.CurrentApp?.TrackClick("floating_stats_topmost", new Dictionary - { - ["enabled"] = enabled - }); - }; - menu.Items.Add(topmostItem); - - var lockItem = new MenuItem - { - Header = KeyStats.Properties.Strings.FloatingStats_LockPosition, - IsCheckable = true, - IsChecked = StatsManager.Instance.Settings.FloatingStatsPositionLocked - }; - lockItem.Click += (_, _) => - { - var enabled = lockItem.IsChecked; - StatsManager.Instance.Settings.FloatingStatsPositionLocked = enabled; - StatsManager.Instance.SaveSettings(); - UpdateDragCursor(); - App.CurrentApp?.TrackClick("floating_stats_position_lock", new Dictionary - { - ["enabled"] = enabled - }); - }; - menu.Items.Add(lockItem); - menu.Items.Add(new Separator()); - - var openDetailsItem = new MenuItem + var settingsItem = new MenuItem { - Header = KeyStats.Properties.Strings.FloatingStats_OpenDetails + Header = KeyStats.Properties.Strings.Tray_Settings }; - openDetailsItem.Click += (_, _) => + settingsItem.Click += (_, _) => { - App.CurrentApp?.TrackClick("floating_stats_open_details"); - App.CurrentApp?.ShowMainWindow(); - }; - menu.Items.Add(openDetailsItem); - - var hideItem = new MenuItem - { - Header = KeyStats.Properties.Strings.FloatingStats_Hide - }; - hideItem.Click += (_, _) => - { - App.CurrentApp?.TrackClick("floating_stats_hide"); - Dispatcher.BeginInvoke(new Action(() => App.CurrentApp?.SetFloatingStatsVisible(false))); + App.CurrentApp?.TrackClick("floating_stats_settings"); + App.CurrentApp?.ShowSettingsWindow(); }; - menu.Items.Add(hideItem); + menu.Items.Add(settingsItem); return menu; } - private void OpenMetricSelector(bool isPrimary) - { - if (_metricSelectorMenu != null) - { - _metricSelectorMenu.IsOpen = false; - } - var selector = new ContextMenu - { - PlacementTarget = RootBorder, - Placement = PlacementMode.MousePoint - }; - _metricSelectorMenu = selector; - selector.Closed += (_, _) => - { - if (ReferenceEquals(_metricSelectorMenu, selector)) - { - _metricSelectorMenu = null; - } - }; - - var selectedMetric = isPrimary ? _viewModel.PrimaryMetricId : _viewModel.SecondaryMetricId; - var otherMetric = isPrimary ? _viewModel.SecondaryMetricId : _viewModel.PrimaryMetricId; - - foreach (var metricId in FloatingStatsViewModel.AvailableMetricIds) - { - var capturedMetricId = metricId; - var item = new MenuItem - { - Header = FloatingStatsViewModel.GetMetricLabel(metricId), - IsCheckable = true, - IsChecked = string.Equals(metricId, selectedMetric, StringComparison.Ordinal), - IsEnabled = !string.Equals(metricId, otherMetric, StringComparison.Ordinal) - }; - item.Click += (_, _) => - { - _viewModel.SetMetric(isPrimary, capturedMetricId); - RootBorder.ContextMenu = BuildContextMenu(); - App.CurrentApp?.TrackClick("floating_stats_metric_change", new Dictionary - { - ["slot"] = isPrimary ? "primary" : "secondary", - ["metric"] = capturedMetricId - }); - }; - selector.Items.Add(item); - } - - Dispatcher.BeginInvoke(new Action(() => selector.IsOpen = true)); - } - private void UpdateDragCursor() { RootBorder.Cursor = StatsManager.Instance.Settings.FloatingStatsPositionLocked diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml index 17ae3a4..9f45367 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml @@ -99,6 +99,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs index e4df546..99d12d8 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs @@ -5,6 +5,7 @@ using System.Windows.Controls; using KeyStats.Helpers; using KeyStats.Services; +using KeyStats.ViewModels; namespace KeyStats.Views; @@ -12,6 +13,7 @@ public partial class SettingsWindow : Window { private const string GitHubUrl = "https://github.com/debugtheworldbot/keyStats"; private bool _isLoadingTaskbarStats = true; + private bool _isLoadingFloatingStats = true; public SettingsWindow() { @@ -25,6 +27,7 @@ public SettingsWindow() private void OnLoaded(object sender, RoutedEventArgs e) { ApplyWindowBackdrop(); + LoadFloatingStatsControls(); _isLoadingTaskbarStats = true; TaskbarStatsCheckBox.IsChecked = StatsManager.Instance.Settings.TaskbarStatsEnabled; _isLoadingTaskbarStats = false; @@ -157,6 +160,80 @@ 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; + RefreshFloatingStatsControls(); + _isLoadingFloatingStats = false; + } + + private void RefreshFloatingStatsControls() + { + var settings = StatsManager.Instance.Settings; + FloatingPrimaryMetricComboBox.SelectedValue = settings.FloatingStatsPrimaryMetric; + FloatingSecondaryMetricComboBox.SelectedValue = settings.FloatingStatsSecondaryMetric; + FloatingTopmostCheckBox.IsChecked = settings.FloatingStatsTopmost; + FloatingLockPositionCheckBox.IsChecked = settings.FloatingStatsPositionLocked; + } + + 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 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 TaskbarStatsVisibility_Changed(object sender, RoutedEventArgs e) { if (_isLoadingTaskbarStats) @@ -272,4 +349,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; } + } } From 12a3c32095e2dce51455f4cd5c4bff645a240129 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 12:56:29 +0800 Subject: [PATCH 06/20] feat: add floating stats layout options --- .../KeyStats/Models/AppSettings.cs | 5 + .../KeyStats/Properties/Strings.cs | 3 + .../KeyStats/Properties/Strings.resx | 3 + .../KeyStats/Properties/Strings.zh-Hans.resx | 3 + .../KeyStats/Properties/Strings.zh-Hant.resx | 3 + .../KeyStats/Views/FloatingStatsWindow.xaml | 105 ++++++++++++------ .../Views/FloatingStatsWindow.xaml.cs | 30 ++++- .../KeyStats/Views/SettingsWindow.xaml | 16 +++ .../KeyStats/Views/SettingsWindow.xaml.cs | 47 ++++++++ KeyStats.Windows/design-qa.md | 48 ++++---- 10 files changed, 205 insertions(+), 58 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index becdd11..5bb22aa 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -6,6 +6,8 @@ namespace KeyStats.Models; public class AppSettings { public const double DefaultMouseMetersPerPixel = 0.00005; + public const string FloatingStatsSingleRowLayoutMode = "singleRow"; + public const string FloatingStatsDoubleRowLayoutMode = "doubleRow"; [JsonPropertyName("notificationsEnabled")] public bool NotificationsEnabled { get; set; } @@ -67,6 +69,9 @@ public class AppSettings [JsonPropertyName("floatingStatsSecondaryMetric")] public string FloatingStatsSecondaryMetric { get; set; } = "totalClicks"; + [JsonPropertyName("floatingStatsLayoutMode")] + public string FloatingStatsLayoutMode { get; set; } = FloatingStatsSingleRowLayoutMode; + [JsonPropertyName("floatingStatsLeft")] public double? FloatingStatsLeft { get; set; } diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.cs b/KeyStats.Windows/KeyStats/Properties/Strings.cs index 0ddb0b1..cc27213 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.cs +++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs @@ -174,6 +174,9 @@ public static class Strings 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_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)); diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.resx b/KeyStats.Windows/KeyStats/Properties/Strings.resx index 4ed17d8..bd67115 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx @@ -79,6 +79,9 @@ TODAY First Metric Second Metric + Display Layout + One Row + Two Rows Always on Top Lock Position Open Detailed Stats diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx index f1e0df3..0c4b198 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx @@ -79,6 +79,9 @@ 今日 第一项 第二项 + 展示方式 + 一排 + 两排 始终置顶 锁定位置 打开详细统计 diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx index 0b8820b..5b44ea3 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx @@ -79,6 +79,9 @@ 今日 第一項 第二項 + 顯示方式 + 單排 + 雙排 永遠置頂 鎖定位置 開啟詳細統計 diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index 802ec84..cf301a1 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -3,7 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:p="clr-namespace:KeyStats.Properties" Title="{x:Static p:Strings.FloatingStats_WindowTitle}" - Width="136" + Width="104" Height="36" WindowStyle="None" AllowsTransparency="False" @@ -23,41 +23,80 @@ Cursor="SizeAll" SnapsToDevicePixels="True" MouseLeftButtonDown="RootBorder_MouseLeftButtonDown"> - - - - - - + + + + + + + - + - + - + + + + + + + + + + + + + + + + diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs index 27e12ee..f158fed 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs @@ -6,6 +6,7 @@ using System.Windows.Media; using System.Windows.Threading; using KeyStats.Helpers; +using KeyStats.Models; using KeyStats.Services; using KeyStats.ViewModels; using Microsoft.Win32; @@ -16,6 +17,10 @@ namespace KeyStats.Views; public partial class FloatingStatsWindow : Window { private const double EdgeMargin = 16; + private const double SingleRowWidth = 104; + private const double SingleRowHeight = 36; + private const double DoubleRowWidth = 72; + private const double DoubleRowHeight = 52; private readonly FloatingStatsViewModel _viewModel; private readonly DispatcherTimer _positionSaveTimer; private bool _isLoaded; @@ -36,6 +41,7 @@ public FloatingStatsWindow() var settings = StatsManager.Instance.Settings; Topmost = settings.FloatingStatsTopmost; UpdateDragCursor(); + ApplyLayoutSettings(); SourceInitialized += OnSourceInitialized; Loaded += OnLoaded; @@ -58,6 +64,10 @@ public void ApplyBehaviorSettings() var settings = StatsManager.Instance.Settings; Topmost = settings.FloatingStatsTopmost; UpdateDragCursor(); + if (ApplyLayoutSettings()) + { + EnsureVisiblePosition(); + } } private void OnSourceInitialized(object? sender, EventArgs e) @@ -74,7 +84,8 @@ private void OnLoaded(object sender, RoutedEventArgs e) App.CurrentApp?.TrackPageView("floating_stats", new Dictionary { ["primary_metric"] = _viewModel.PrimaryMetricId, - ["secondary_metric"] = _viewModel.SecondaryMetricId + ["secondary_metric"] = _viewModel.SecondaryMetricId, + ["layout"] = StatsManager.Instance.Settings.FloatingStatsLayoutMode }); } @@ -161,6 +172,23 @@ private void UpdateDragCursor() : Cursors.SizeAll; } + private bool ApplyLayoutSettings() + { + var useDoubleRow = string.Equals( + StatsManager.Instance.Settings.FloatingStatsLayoutMode, + AppSettings.FloatingStatsDoubleRowLayoutMode, + StringComparison.Ordinal); + var targetWidth = useDoubleRow ? DoubleRowWidth : SingleRowWidth; + var targetHeight = useDoubleRow ? DoubleRowHeight : SingleRowHeight; + 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) diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml index 9f45367..7e96406 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml @@ -111,6 +111,7 @@ + @@ -142,6 +143,21 @@ SelectedValuePath="Id" SelectionChanged="FloatingMetric_SelectionChanged" Margin="0,8,0,0"/> + + + + + + diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs index 99d12d8..47bd51d 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs @@ -4,6 +4,7 @@ using System.Windows; using System.Windows.Controls; using KeyStats.Helpers; +using KeyStats.Models; using KeyStats.Services; using KeyStats.ViewModels; @@ -179,6 +180,19 @@ 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; } @@ -211,6 +225,39 @@ private void FloatingMetric_SelectionChanged(object sender, SelectionChangedEven }); } + 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) diff --git a/KeyStats.Windows/design-qa.md b/KeyStats.Windows/design-qa.md index 50beaa1..49f5700 100644 --- a/KeyStats.Windows/design-qa.md +++ b/KeyStats.Windows/design-qa.md @@ -1,48 +1,48 @@ # Floating Stats Design QA -- Source visual truth: `C:\Users\t\AppData\Local\Temp\browser-use\assets\5c950f33-06aa-4851-b388-883f9b9d6750\d0011dafe24be0ae.png` -- Rendered implementation: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-implementation-taskbar-size.png` -- Combined comparison: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-comparison-taskbar-size.png` -- Viewport: KeyStats floating surface at 136 × 36 device-independent pixels, rendered at 96 DPI / 1× density -- Pixel dimensions: source 304 × 140; implementation 136 × 36; comparison canvas 478 × 98 -- State: Windows light theme, today's key presses and total mouse clicks, representative non-zero values -- Normalization: the 278 × 54 TrafficMonitor floating-window region was cropped without density scaling; the KeyStats surface was rendered at its production XAML size. The source tooltip was excluded because it is a transient secondary state. +- Source feedback capture: `C:\Users\t\AppData\Local\Temp\codex-clipboard-67a8ee63-df85-4c65-ab56-4e22ddc7dab7.png` +- Single-row implementation: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-single-row-tight.png` +- Double-row implementation: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-double-row.png` +- Combined comparison: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-layout-comparison.png` +- Viewports: single row 104 × 36 DIPs; double row 72 × 52 DIPs; both rendered at 96 DPI / 1× density +- Pixel dimensions: feedback capture 365 × 103; single-row implementation 104 × 36; double-row implementation 72 × 52; comparison canvas 790 × 150 +- State: Windows light theme, values 225 and 252 +- Normalization: the implementation captures are rendered at 1× and enlarged to 2× in the combined comparison to approximate the high-DPI scale of the supplied feedback screenshot. ## Full-view comparison evidence -The comparison confirms a compact, bordered, two-column readout with a centered divider. The latest 136 × 36 frame deliberately follows KeyStats' taskbar-scale typography and density rather than TrafficMonitor's larger reference dimensions. Visible labels and icons remain omitted; metric identity is available through hover tooltips and the right-click configuration menu. +The revised single-row surface materially reduces the wide outer and inter-value whitespace visible in the supplied screenshot while preserving two clearly separated values. The new double-row option uses a narrower vertical card with a horizontal divider and equal row heights. Both layouts retain the same typography, border, radius, material, and interaction affordances. ## Required fidelity surfaces -- Fonts and typography: both values use the same Segoe UI 11 px semibold treatment as the taskbar statistic values, with display-mode formatting, ClearType rendering, consistent baseline, centering, and ellipsis behavior. -- Spacing and layout rhythm: the two equal-width tracks use 6 px side padding, 3 px vertical padding, a 9 px divider gutter, and a centered 18 px hairline. The 136 × 36 frame remains readable while materially reducing empty space. -- Colors and visual tokens: the surface retains KeyStats dynamic primary text, divider, translucent backdrop tint, and popup border resources. The light-state capture has clear contrast; dark mode continues through ThemeManager. -- Image quality and asset fidelity: there are no visible raster images, icons, or decorative assets in the revised component. -- Copy and content: only the two requested statistic values are visible. Metric names and exact expanded values remain accessible in native tooltips and menus rather than permanent chrome. +- Fonts and typography: both layouts retain the taskbar-aligned Segoe UI 11 px semibold values with display-mode formatting and ClearType rendering. +- Spacing and layout rhythm: single row is reduced from 136 × 36 to 104 × 36, with 4 px horizontal padding and a 7 px divider gutter. Double row is 72 × 52 with 4 px horizontal padding, 3 px vertical padding, and a centered 42 px divider. +- Colors and visual tokens: both layouts continue using the KeyStats dynamic primary text, divider, translucent backdrop tint, and popup border resources. +- Image quality and asset fidelity: neither layout contains raster imagery, icons, or decorative assets. +- Copy and content: only the two selected statistic values are visible; labels and exact expanded values remain available through settings and tooltips. ## Focused-region comparison evidence -No separate focused crop was required because both numbers, the divider, border, radius, padding, and alignment are legible at original 1× resolution in the combined comparison. +No separate focused crop was needed because the values, separators, border, padding, and alignment are all legible in the combined comparison at the supplied high-DPI presentation scale. ## Findings - No actionable P0, P1, or P2 differences. -- Accepted intentional difference: TrafficMonitor shows labels and four values, while KeyStats follows the user's explicit two-number-only requirement. -- Accepted intentional difference: the latest KeyStats surface is smaller than TrafficMonitor to match the existing taskbar statistic size requested by the user. -- Accepted intentional difference: KeyStats keeps its neutral translucent theme instead of copying TrafficMonitor's green skin. +- Accepted intentional difference: the double-row layout is narrower and taller than the supplied single-row capture because it prioritizes vertical stacking. +- P3: very long formatted distance values may truncate in the compact card; the full value remains available in the tooltip. ## Comparison history -- Earlier implementation: 248 × 84 with “Today,” icons, labels, and values. -- User-directed revision: removed all visible labels and icons, reduced the frame to 184 × 56, and centered the two values. -- Latest user-directed revision: matched the taskbar's 11 px value typography, reduced the frame to 136 × 36, tightened the outer padding and divider gutter, and enabled the taskbar's display/ClearType text rendering. -- Post-fix evidence: the latest taskbar-sized capture shows no clipping, overlap, or alignment issue. +- Earlier state: 136 × 36 single-row surface with excess horizontal whitespace at the user's display scale. +- Revision: reduced single-row width to 104 DIPs and added a 72 × 52 double-row layout selectable from Settings. +- Post-fix evidence: both exact production-XAML renders show centered values with no overlap or clipping for representative counts. ## Implementation checklist -- Production XAML rendered from the exact floating-window source file. +- Single-row and double-row production XAML rendered and inspected. +- Layout selection persists through the additive settings model and applies immediately. +- Window size changes re-clamp the saved position to the visible work area. - Debug build completed with 0 warnings and 0 errors. -- Two-value hover tooltips preserve metric identification and exact values. -- Move, position persistence, metric selection, topmost, position lock, details, and hide paths remain intact. +- English, Simplified Chinese, and Traditional Chinese resources remain in parity. final result: passed From d2221844f09bbcb5eb3d35ae460f5fefbfaf6911 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 12:59:12 +0800 Subject: [PATCH 07/20] fix: keep floating stats above taskbar --- .../Views/FloatingStatsWindow.xaml.cs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs index f158fed..b0a4465 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs @@ -146,6 +146,10 @@ private void RootBorder_MouseLeftButtonDown(object sender, MouseButtonEventArgs { // The mouse may be released before WPF enters the native drag loop. } + finally + { + EnsureVisiblePosition(); + } } private ContextMenu BuildContextMenu() @@ -196,6 +200,7 @@ private void OnLocationChanged(object? sender, EventArgs e) return; } + ClampCurrentPositionToWorkingArea(); _positionSaveTimer.Stop(); _positionSaveTimer.Start(); } @@ -203,6 +208,11 @@ private void OnLocationChanged(object? sender, EventArgs e) 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; @@ -246,6 +256,13 @@ private void EnsureVisiblePosition() return; } + ClampCurrentPositionToWorkingArea(); + _positionSaveTimer.Stop(); + SaveCurrentPosition(); + } + + private void ClampCurrentPositionToWorkingArea() + { var workingAreas = GetWorkingAreasInDips(); var preferredArea = workingAreas.Count > 0 ? workingAreas[0] @@ -264,11 +281,6 @@ private void EnsureVisiblePosition() { _isRestoringPosition = false; } - - var settings = StatsManager.Instance.Settings; - settings.FloatingStatsLeft = Left; - settings.FloatingStatsTop = Top; - StatsManager.Instance.SaveSettings(); } private List GetWorkingAreasInDips() From 3728d3f81f0e80aeadac7891853c67a3be271434 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 13:02:01 +0800 Subject: [PATCH 08/20] fix: render taskbar stats on windows 11 --- .../KeyStats/Helpers/TaskbarStatsHost.cs | 219 ++-------- .../Views/TaskbarStatsNativeControl.cs | 393 ++++++++++++++++++ .../KeyStats/Views/TaskbarStatsView.xaml | 101 ----- .../KeyStats/Views/TaskbarStatsView.xaml.cs | 152 ------- 4 files changed, 432 insertions(+), 433 deletions(-) create mode 100644 KeyStats.Windows/KeyStats/Views/TaskbarStatsNativeControl.cs delete mode 100644 KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml delete mode 100644 KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml.cs diff --git a/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs b/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs index fc2dc9a..55d8e09 100644 --- a/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs +++ b/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs @@ -1,14 +1,11 @@ using System; -using System.Windows; -using System.Windows.Interop; -using System.Windows.Media; using System.Windows.Threading; using KeyStats.Views; namespace KeyStats.Helpers; /// -/// Hosts the compact statistics view as its own HWND beside the notification area. +/// Hosts the compact statistics control as a native child of the primary taskbar. /// public sealed class TaskbarStatsHost : IDisposable { @@ -16,14 +13,10 @@ public sealed class TaskbarStatsHost : IDisposable private const int BaseHeight = 40; private const int BaseEdgeInset = 2; private const int BaseFallbackNotificationWidth = 96; - private const int WM_MOUSEACTIVATE = 0x0021; - private const int MA_NOACTIVATE = 3; private readonly DispatcherTimer _positionTimer; - private HwndSource? _source; - private TaskbarStatsView? _view; + private TaskbarStatsNativeControl? _control; private IntPtr _taskbarHandle; - private bool _isEmbedded; private bool _enabled; private bool _isDisposed; @@ -36,7 +29,6 @@ public TaskbarStatsHost() Interval = TimeSpan.FromSeconds(1) }; _positionTimer.Tick += OnPositionTimerTick; - ThemeManager.Instance.ThemeChanged += OnThemeChanged; } public void SetEnabled(bool enabled) @@ -50,7 +42,7 @@ public void SetEnabled(bool enabled) if (!enabled) { _positionTimer.Stop(); - DestroySource(); + DestroyControl(); return; } @@ -65,7 +57,7 @@ public void Recreate() return; } - DestroySource(); + DestroyControl(); EnsureHost(); } @@ -79,8 +71,7 @@ public void Dispose() _isDisposed = true; _positionTimer.Stop(); _positionTimer.Tick -= OnPositionTimerTick; - ThemeManager.Instance.ThemeChanged -= OnThemeChanged; - DestroySource(); + DestroyControl(); } private void OnPositionTimerTick(object? sender, EventArgs e) @@ -88,20 +79,6 @@ private void OnPositionTimerTick(object? sender, EventArgs e) EnsureHost(); } - private void OnThemeChanged() - { - if (_isDisposed) - { - return; - } - - Application.Current?.Dispatcher.BeginInvoke(new Action(() => - { - ApplyCompositionBackground(); - _view?.InvalidateVisual(); - })); - } - private void EnsureHost() { if (!_enabled || _isDisposed) @@ -112,19 +89,18 @@ private void EnsureHost() var taskbar = NativeInterop.FindWindow("Shell_TrayWnd", null); if (taskbar == IntPtr.Zero || !NativeInterop.IsWindow(taskbar)) { - DestroySource(); + DestroyControl(); return; } - var sourceHandle = GetSourceHandle(); + var controlHandle = GetControlHandle(); var parentChanged = _taskbarHandle != IntPtr.Zero && _taskbarHandle != taskbar; - var embeddedParentChanged = _isEmbedded && - sourceHandle != IntPtr.Zero && - NativeInterop.GetParent(sourceHandle) != taskbar; - if (sourceHandle == IntPtr.Zero || parentChanged || embeddedParentChanged) + var nativeParentChanged = controlHandle != IntPtr.Zero && + NativeInterop.GetParent(controlHandle) != taskbar; + if (controlHandle == IntPtr.Zero || parentChanged || nativeParentChanged) { - DestroySource(); - TryCreateHost(taskbar); + DestroyControl(); + TryCreateControl(taskbar); return; } @@ -134,134 +110,50 @@ private void EnsureHost() } } - private void TryCreateHost(IntPtr taskbar) + private void TryCreateControl(IntPtr taskbar) { if (!TryGetPlacement(taskbar, out var placement)) { return; } - _taskbarHandle = taskbar; + TaskbarStatsNativeControl? control = null; try { - CreateSource(placement, embedded: true); - return; - } - catch (Exception ex) - { - Console.WriteLine($"Taskbar stats embedding failed, using overlay fallback: {ex.Message}"); - DestroySource(); + control = new TaskbarStatsNativeControl(); + control.CreateInTaskbar(taskbar); _taskbarHandle = taskbar; - } - - try - { - CreateSource(placement, embedded: false); - } - catch (Exception ex) - { - Console.WriteLine($"Taskbar stats overlay fallback failed: {ex.Message}"); - DestroySource(); - } - } - - private void CreateSource(Placement placement, bool embedded) - { - var parameters = new HwndSourceParameters("KeyStatsTaskbarStatsWindow") - { - ParentWindow = embedded ? _taskbarHandle : IntPtr.Zero, - PositionX = embedded ? placement.RelativeX : placement.ScreenX, - PositionY = embedded ? placement.RelativeY : placement.ScreenY, - Width = placement.Width, - Height = placement.Height, - WindowStyle = embedded - ? NativeInterop.WS_CHILD | - NativeInterop.WS_VISIBLE | - NativeInterop.WS_CLIPSIBLINGS | - NativeInterop.WS_CLIPCHILDREN - : NativeInterop.WS_POPUP | NativeInterop.WS_VISIBLE, - ExtendedWindowStyle = NativeInterop.WS_EX_TOOLWINDOW | - NativeInterop.WS_EX_NOACTIVATE | - NativeInterop.WS_EX_NOPARENTNOTIFY - }; - - HwndSource? source = null; - TaskbarStatsView? view = null; - try - { - source = new HwndSource(parameters); - if (source.Handle == IntPtr.Zero) - { - throw new InvalidOperationException("The taskbar statistics HWND was not created."); - } - - view = new TaskbarStatsView(); - view.SetCompactMode(placement.Compact); - source.RootVisual = view; - source.AddHook(WindowHook); - - _source = source; - _view = view; - _isEmbedded = embedded; - ApplyCompositionBackground(); + _control = control; ApplyPlacement(placement); } - catch + catch (Exception ex) { - view?.Cleanup(); - source?.Dispose(); - throw; + Console.WriteLine($"Taskbar stats native window creation failed: {ex.Message}"); + control?.Dispose(); + _control = null; + _taskbarHandle = IntPtr.Zero; } } - private IntPtr WindowHook(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) - { - if (msg == WM_MOUSEACTIVATE) - { - handled = true; - return new IntPtr(MA_NOACTIVATE); - } - - return IntPtr.Zero; - } - private void ApplyPlacement(Placement placement) { - var handle = GetSourceHandle(); + var handle = GetControlHandle(); if (handle == IntPtr.Zero) { return; } - _view?.SetCompactMode(placement.Compact); - var x = _isEmbedded ? placement.RelativeX : placement.ScreenX; - var y = _isEmbedded ? placement.RelativeY : placement.ScreenY; + _control?.SetCompactMode(placement.Compact); NativeInterop.SetWindowPos( handle, - _isEmbedded ? NativeInterop.HWND_TOP : NativeInterop.HWND_TOPMOST, - x, - y, + NativeInterop.HWND_TOP, + placement.RelativeX, + placement.RelativeY, placement.Width, placement.Height, NativeInterop.SWP_NOACTIVATE | NativeInterop.SWP_SHOWWINDOW); } - private void ApplyCompositionBackground() - { - if (_source?.CompositionTarget == null) - { - return; - } - - var color = Colors.Transparent; - if (Application.Current?.Resources["SurfaceColor"] is Color surfaceColor) - { - color = surfaceColor; - } - - _source.CompositionTarget.BackgroundColor = color; - } - private static bool TryGetPlacement(IntPtr taskbar, out Placement placement) { placement = default; @@ -314,14 +206,7 @@ private static bool TryGetPlacement(IntPtr taskbar, out Placement placement) compact = true; } - placement = new Placement( - relativeX, - relativeY, - taskbarRect.Left + relativeX, - taskbarRect.Top + relativeY, - width, - height, - compact); + placement = new Placement(relativeX, relativeY, width, height, compact); return true; } @@ -330,65 +215,41 @@ private static int Scale(int value, double scale) return Math.Max(1, (int)Math.Round(value * scale, MidpointRounding.AwayFromZero)); } - private IntPtr GetSourceHandle() + private IntPtr GetControlHandle() { - try - { - var handle = _source?.Handle ?? IntPtr.Zero; - return handle != IntPtr.Zero && NativeInterop.IsWindow(handle) ? handle : IntPtr.Zero; - } - catch (ObjectDisposedException) + if (_control == null || !_control.IsHandleCreated) { return IntPtr.Zero; } + + var handle = _control.Handle; + return handle != IntPtr.Zero && NativeInterop.IsWindow(handle) ? handle : IntPtr.Zero; } - private void DestroySource() + private void DestroyControl() { - _view?.Cleanup(); - _view = null; - - if (_source != null) + if (_control != null) { try { - _source.RemoveHook(WindowHook); + _control.Dispose(); } catch (InvalidOperationException) { - // Explorer may have already destroyed the child HWND. - } - - try - { - _source.Dispose(); - } - catch (ObjectDisposedException) - { - // The source was disposed as part of taskbar recreation. + // Explorer may have already destroyed the cross-process child HWND. } - _source = null; + _control = null; } _taskbarHandle = IntPtr.Zero; - _isEmbedded = false; } private readonly struct Placement { - public Placement( - int relativeX, - int relativeY, - int screenX, - int screenY, - int width, - int height, - bool compact) + public Placement(int relativeX, int relativeY, int width, int height, bool compact) { RelativeX = relativeX; RelativeY = relativeY; - ScreenX = screenX; - ScreenY = screenY; Width = width; Height = height; Compact = compact; @@ -396,8 +257,6 @@ public Placement( public int RelativeX { get; } public int RelativeY { get; } - public int ScreenX { get; } - public int ScreenY { get; } public int Width { get; } public int Height { get; } public bool Compact { get; } diff --git a/KeyStats.Windows/KeyStats/Views/TaskbarStatsNativeControl.cs b/KeyStats.Windows/KeyStats/Views/TaskbarStatsNativeControl.cs new file mode 100644 index 0000000..cd06912 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Views/TaskbarStatsNativeControl.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using KeyStats.Helpers; +using KeyStats.ViewModels; +using Forms = System.Windows.Forms; + +namespace KeyStats.Views; + +/// +/// GDI-rendered taskbar child window for compatibility with the Windows 11 taskbar compositor. +/// +public sealed class TaskbarStatsNativeControl : Forms.Control +{ + private const int WM_MOUSEACTIVATE = 0x0021; + private const int MA_NOACTIVATE = 3; + + private readonly FloatingStatsViewModel _viewModel; + private readonly Forms.ToolTip _toolTip; + private readonly Dictionary _primaryMetricItems = + new(StringComparer.Ordinal); + private readonly Dictionary _secondaryMetricItems = + new(StringComparer.Ordinal); + private IntPtr _taskbarParent; + private bool _compact; + private bool _hovered; + private bool _isDisposed; + + public TaskbarStatsNativeControl() + { + SetStyle( + Forms.ControlStyles.UserPaint | + Forms.ControlStyles.AllPaintingInWmPaint | + Forms.ControlStyles.OptimizedDoubleBuffer | + Forms.ControlStyles.ResizeRedraw | + Forms.ControlStyles.Opaque, + true); + + TabStop = false; + _viewModel = new FloatingStatsViewModel(); + _viewModel.PropertyChanged += OnViewModelPropertyChanged; + _toolTip = new Forms.ToolTip + { + ShowAlways = true, + InitialDelay = 450, + ReshowDelay = 100 + }; + ContextMenuStrip = CreateContextMenu(); + ThemeManager.Instance.ThemeChanged += OnThemeChanged; + UpdateToolTip(); + } + + public void CreateInTaskbar(IntPtr taskbarParent) + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(TaskbarStatsNativeControl)); + } + + if (taskbarParent == IntPtr.Zero) + { + throw new ArgumentException("A valid taskbar HWND is required.", nameof(taskbarParent)); + } + + _taskbarParent = taskbarParent; + CreateControl(); + if (!IsHandleCreated) + { + throw new InvalidOperationException("The native taskbar statistics HWND was not created."); + } + } + + public void SetCompactMode(bool compact) + { + if (_compact == compact) + { + return; + } + + _compact = compact; + Invalidate(); + } + + protected override Forms.CreateParams CreateParams + { + get + { + var parameters = base.CreateParams; + parameters.Caption = "KeyStatsTaskbarStatsWindow"; + parameters.Parent = _taskbarParent; + parameters.Style = NativeInterop.WS_CHILD | + NativeInterop.WS_VISIBLE | + NativeInterop.WS_CLIPSIBLINGS | + NativeInterop.WS_CLIPCHILDREN; + parameters.ExStyle = NativeInterop.WS_EX_TOOLWINDOW | + NativeInterop.WS_EX_NOACTIVATE | + NativeInterop.WS_EX_NOPARENTNOTIFY; + return parameters; + } + } + + protected override void OnPaintBackground(Forms.PaintEventArgs e) + { + e.Graphics.Clear(GetPalette().Background); + } + + protected override void OnPaint(Forms.PaintEventArgs e) + { + base.OnPaint(e); + + var palette = GetPalette(); + e.Graphics.Clear(_hovered ? palette.HoverBackground : palette.Background); + e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + + // Use the actual HWND height instead of DeviceDpi. Explorer and WinForms can + // report different DPI contexts for a cross-process taskbar child on Windows 11. + var scale = Math.Max(0.75f, ClientSize.Height / 40f); + var firstRowHeight = ClientSize.Height / 2; + var secondRowHeight = ClientSize.Height - firstRowHeight; + using var labelFont = new Font( + "Microsoft YaHei UI", + Math.Max(9f, 10f * scale), + FontStyle.Regular, + GraphicsUnit.Pixel); + using var valueFont = new Font( + "Microsoft YaHei UI", + Math.Max(10f, 11f * scale), + FontStyle.Bold, + GraphicsUnit.Pixel); + using var dividerPen = new Pen(palette.Divider, Math.Max(1f, scale * 0.5f)); + + DrawRow(e.Graphics, new Rectangle(0, 0, ClientSize.Width, firstRowHeight), + _viewModel.PrimaryLabel, _viewModel.PrimaryValue, labelFont, valueFont, palette, scale); + + var dividerY = Math.Max(0, firstRowHeight - 1); + e.Graphics.DrawLine(dividerPen, 0, dividerY, ClientSize.Width, dividerY); + + DrawRow(e.Graphics, new Rectangle(0, firstRowHeight, ClientSize.Width, secondRowHeight), + _viewModel.SecondaryLabel, _viewModel.SecondaryValue, labelFont, valueFont, palette, scale); + } + + protected override void OnMouseEnter(EventArgs e) + { + base.OnMouseEnter(e); + _hovered = true; + Invalidate(); + } + + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + _hovered = false; + Invalidate(); + } + + protected override void OnMouseDoubleClick(Forms.MouseEventArgs e) + { + base.OnMouseDoubleClick(e); + if (e.Button != Forms.MouseButtons.Left) + { + return; + } + + App.CurrentApp?.TrackClick("taskbar_stats_open_details"); + App.CurrentApp?.ShowMainWindow(); + } + + protected override void WndProc(ref Forms.Message message) + { + if (message.Msg == WM_MOUSEACTIVATE) + { + message.Result = new IntPtr(MA_NOACTIVATE); + return; + } + + base.WndProc(ref message); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + _isDisposed = true; + ThemeManager.Instance.ThemeChanged -= OnThemeChanged; + _viewModel.PropertyChanged -= OnViewModelPropertyChanged; + _viewModel.Cleanup(); + _toolTip.Dispose(); + ContextMenuStrip?.Dispose(); + ContextMenuStrip = null; + } + + base.Dispose(disposing); + } + + private void DrawRow( + Graphics graphics, + Rectangle bounds, + string label, + string value, + Font labelFont, + Font valueFont, + Palette palette, + float scale) + { + var horizontalPadding = Math.Max(4, (int)Math.Round(6 * scale)); + var markerSize = Math.Max(3, (int)Math.Round(3 * scale)); + var markerX = horizontalPadding; + var markerY = bounds.Top + Math.Max(0, (bounds.Height - markerSize) / 2); + using var markerBrush = new SolidBrush(palette.Accent); + graphics.FillEllipse(markerBrush, markerX, markerY, markerSize, markerSize); + + var contentLeft = markerX + markerSize + Math.Max(3, (int)Math.Round(4 * scale)); + var contentRight = Math.Max(contentLeft, bounds.Right - horizontalPadding); + var textFlags = Forms.TextFormatFlags.NoPrefix | + Forms.TextFormatFlags.NoPadding | + Forms.TextFormatFlags.SingleLine | + Forms.TextFormatFlags.VerticalCenter | + Forms.TextFormatFlags.EndEllipsis; + + if (_compact) + { + Forms.TextRenderer.DrawText(graphics, value, valueFont, + Rectangle.FromLTRB(contentLeft, bounds.Top, contentRight, bounds.Bottom), + palette.PrimaryText, textFlags | Forms.TextFormatFlags.Right); + return; + } + + var measuredValue = Forms.TextRenderer.MeasureText( + graphics, value, valueFont, new Size(int.MaxValue, bounds.Height), textFlags).Width; + var minimumValueWidth = Math.Max(38, (int)Math.Round(46 * scale)); + var valueWidth = Math.Min( + Math.Max(minimumValueWidth, measuredValue), + Math.Max(minimumValueWidth, (contentRight - contentLeft) / 2)); + var valueLeft = Math.Max(contentLeft, contentRight - valueWidth); + var labelRight = Math.Max(contentLeft, valueLeft - Math.Max(3, (int)Math.Round(5 * scale))); + + Forms.TextRenderer.DrawText(graphics, label, labelFont, + Rectangle.FromLTRB(contentLeft, bounds.Top, labelRight, bounds.Bottom), + palette.SecondaryText, textFlags); + Forms.TextRenderer.DrawText(graphics, value, valueFont, + Rectangle.FromLTRB(valueLeft, bounds.Top, contentRight, bounds.Bottom), + palette.PrimaryText, textFlags | Forms.TextFormatFlags.Right); + } + + private Forms.ContextMenuStrip CreateContextMenu() + { + var menu = new Forms.ContextMenuStrip(); + menu.Opening += (_, _) => RefreshMetricMenuState(); + + var openDetailsItem = new Forms.ToolStripMenuItem(KeyStats.Properties.Strings.TaskbarStats_OpenDetails); + openDetailsItem.Click += (_, _) => + { + App.CurrentApp?.TrackClick("taskbar_stats_open_details"); + App.CurrentApp?.ShowMainWindow(); + }; + menu.Items.Add(openDetailsItem); + menu.Items.Add(new Forms.ToolStripSeparator()); + + var primaryItem = new Forms.ToolStripMenuItem(KeyStats.Properties.Strings.TaskbarStats_PrimaryMetric); + PopulateMetricMenu(primaryItem, isPrimary: true, _primaryMetricItems); + menu.Items.Add(primaryItem); + + var secondaryItem = new Forms.ToolStripMenuItem(KeyStats.Properties.Strings.TaskbarStats_SecondaryMetric); + PopulateMetricMenu(secondaryItem, isPrimary: false, _secondaryMetricItems); + menu.Items.Add(secondaryItem); + menu.Items.Add(new Forms.ToolStripSeparator()); + + var hideItem = new Forms.ToolStripMenuItem(KeyStats.Properties.Strings.TaskbarStats_Hide); + hideItem.Click += (_, _) => System.Windows.Application.Current?.Dispatcher.BeginInvoke(new Action(() => + App.CurrentApp?.SetTaskbarStatsEnabled(false, "taskbar_stats_context_menu"))); + menu.Items.Add(hideItem); + return menu; + } + + private void PopulateMetricMenu( + Forms.ToolStripMenuItem parent, + bool isPrimary, + IDictionary destination) + { + foreach (var metricId in FloatingStatsViewModel.AvailableMetricIds) + { + var capturedMetricId = metricId; + var item = new Forms.ToolStripMenuItem(FloatingStatsViewModel.GetMetricLabel(metricId)); + item.Click += (_, _) => SelectMetric(isPrimary, capturedMetricId); + destination[metricId] = item; + parent.DropDownItems.Add(item); + } + } + + private void RefreshMetricMenuState() + { + var primaryMetric = _viewModel.PrimaryMetricId; + var secondaryMetric = _viewModel.SecondaryMetricId; + + foreach (var pair in _primaryMetricItems) + { + pair.Value.Checked = string.Equals(pair.Key, primaryMetric, StringComparison.Ordinal); + pair.Value.Enabled = !string.Equals(pair.Key, secondaryMetric, StringComparison.Ordinal); + } + + foreach (var pair in _secondaryMetricItems) + { + pair.Value.Checked = string.Equals(pair.Key, secondaryMetric, StringComparison.Ordinal); + pair.Value.Enabled = !string.Equals(pair.Key, primaryMetric, StringComparison.Ordinal); + } + } + + private void SelectMetric(bool isPrimary, string metricId) + { + var currentMetric = isPrimary ? _viewModel.PrimaryMetricId : _viewModel.SecondaryMetricId; + if (string.Equals(currentMetric, metricId, StringComparison.Ordinal)) + { + return; + } + + _viewModel.SetMetric(isPrimary, metricId); + App.CurrentApp?.TrackClick("taskbar_stats_metric_change", new Dictionary + { + ["row"] = isPrimary ? "primary" : "secondary", + ["metric"] = metricId + }); + } + + private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_isDisposed) + { + return; + } + + UpdateToolTip(); + Invalidate(); + } + + private void OnThemeChanged() + { + if (_isDisposed) + { + return; + } + + if (IsHandleCreated && InvokeRequired) + { + BeginInvoke(new Action(Invalidate)); + return; + } + + Invalidate(); + } + + private void UpdateToolTip() + { + _toolTip.SetToolTip( + this, + $"{_viewModel.PrimaryFullValue}{Environment.NewLine}{_viewModel.SecondaryFullValue}"); + } + + private static Palette GetPalette() + { + if (ThemeManager.Instance.IsDarkTheme) + { + return new Palette( + Color.FromArgb(32, 32, 32), Color.FromArgb(45, 45, 45), Color.White, + Color.FromArgb(197, 197, 197), Color.FromArgb(0, 120, 212), Color.FromArgb(61, 61, 61)); + } + + return new Palette( + Color.FromArgb(250, 250, 250), Color.FromArgb(238, 238, 238), Color.FromArgb(26, 26, 26), + Color.FromArgb(74, 74, 74), Color.FromArgb(0, 103, 192), Color.FromArgb(229, 229, 229)); + } + + private readonly struct Palette + { + public Palette(Color background, Color hoverBackground, Color primaryText, + Color secondaryText, Color accent, Color divider) + { + Background = background; + HoverBackground = hoverBackground; + PrimaryText = primaryText; + SecondaryText = secondaryText; + Accent = accent; + Divider = divider; + } + + public Color Background { get; } + public Color HoverBackground { get; } + public Color PrimaryText { get; } + public Color SecondaryText { get; } + public Color Accent { get; } + public Color Divider { get; } + } +} diff --git a/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml b/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml deleted file mode 100644 index db4d2f0..0000000 --- a/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml.cs b/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml.cs deleted file mode 100644 index a4feae3..0000000 --- a/KeyStats.Windows/KeyStats/Views/TaskbarStatsView.xaml.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Input; -using KeyStats.ViewModels; - -namespace KeyStats.Views; - -public partial class TaskbarStatsView : UserControl -{ - private readonly FloatingStatsViewModel _viewModel; - private readonly Dictionary _primaryMetricItems = new(StringComparer.Ordinal); - private readonly Dictionary _secondaryMetricItems = new(StringComparer.Ordinal); - private bool _isCleanedUp; - - public TaskbarStatsView() - { - InitializeComponent(); - _viewModel = new FloatingStatsViewModel(); - DataContext = _viewModel; - RootBorder.ContextMenu = CreateContextMenu(); - } - - public void Cleanup() - { - if (_isCleanedUp) - { - return; - } - - _isCleanedUp = true; - RootBorder.ContextMenu = null; - _viewModel.Cleanup(); - } - - public void SetCompactMode(bool compact) - { - var visibility = compact ? Visibility.Collapsed : Visibility.Visible; - PrimaryLabelBlock.Visibility = visibility; - SecondaryLabelBlock.Visibility = visibility; - RootBorder.Padding = compact ? new Thickness(3, 2, 3, 2) : new Thickness(6, 2, 6, 2); - } - - private ContextMenu CreateContextMenu() - { - var menu = new ContextMenu(); - menu.Opened += (_, _) => RefreshMetricMenuState(); - - var openDetailsItem = new MenuItem - { - Header = KeyStats.Properties.Strings.TaskbarStats_OpenDetails - }; - openDetailsItem.Click += (_, _) => OpenDetails(); - menu.Items.Add(openDetailsItem); - menu.Items.Add(new Separator()); - - var primaryItem = new MenuItem - { - Header = KeyStats.Properties.Strings.TaskbarStats_PrimaryMetric - }; - PopulateMetricMenu(primaryItem, isPrimary: true, _primaryMetricItems); - menu.Items.Add(primaryItem); - - var secondaryItem = new MenuItem - { - Header = KeyStats.Properties.Strings.TaskbarStats_SecondaryMetric - }; - PopulateMetricMenu(secondaryItem, isPrimary: false, _secondaryMetricItems); - menu.Items.Add(secondaryItem); - menu.Items.Add(new Separator()); - - var hideItem = new MenuItem - { - Header = KeyStats.Properties.Strings.TaskbarStats_Hide - }; - hideItem.Click += (_, _) => Dispatcher.BeginInvoke(new Action(() => - App.CurrentApp?.SetTaskbarStatsEnabled(false, "taskbar_stats_context_menu"))); - menu.Items.Add(hideItem); - - return menu; - } - - private void PopulateMetricMenu( - ItemsControl parent, - bool isPrimary, - IDictionary destination) - { - foreach (var metricId in FloatingStatsViewModel.AvailableMetricIds) - { - var capturedMetricId = metricId; - var item = new MenuItem - { - Header = FloatingStatsViewModel.GetMetricLabel(metricId), - IsCheckable = true, - StaysOpenOnClick = false - }; - item.Click += (_, _) => SelectMetric(isPrimary, capturedMetricId); - destination[metricId] = item; - parent.Items.Add(item); - } - } - - private void RefreshMetricMenuState() - { - var primaryMetric = _viewModel.PrimaryMetricId; - var secondaryMetric = _viewModel.SecondaryMetricId; - - foreach (var pair in _primaryMetricItems) - { - pair.Value.IsChecked = string.Equals(pair.Key, primaryMetric, StringComparison.Ordinal); - pair.Value.IsEnabled = !string.Equals(pair.Key, secondaryMetric, StringComparison.Ordinal); - } - - foreach (var pair in _secondaryMetricItems) - { - pair.Value.IsChecked = string.Equals(pair.Key, secondaryMetric, StringComparison.Ordinal); - pair.Value.IsEnabled = !string.Equals(pair.Key, primaryMetric, StringComparison.Ordinal); - } - } - - private void SelectMetric(bool isPrimary, string metricId) - { - var currentMetric = isPrimary ? _viewModel.PrimaryMetricId : _viewModel.SecondaryMetricId; - if (string.Equals(currentMetric, metricId, StringComparison.Ordinal)) - { - return; - } - - _viewModel.SetMetric(isPrimary, metricId); - App.CurrentApp?.TrackClick("taskbar_stats_metric_change", new Dictionary - { - ["row"] = isPrimary ? "primary" : "secondary", - ["metric"] = metricId - }); - } - - private void OnMouseLeftButtonDown(object sender, MouseButtonEventArgs e) - { - if (e.ChangedButton == MouseButton.Left && e.ClickCount == 2) - { - OpenDetails(); - e.Handled = true; - } - } - - private static void OpenDetails() - { - App.CurrentApp?.TrackClick("taskbar_stats_open_details"); - App.CurrentApp?.ShowMainWindow(); - } -} From 6f066d62225a6f7ea9c2a69088029cb3e8772671 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 13:28:06 +0800 Subject: [PATCH 09/20] refactor: make floating stats ultra compact --- .../KeyStats/Views/FloatingStatsWindow.xaml | 18 +++++++++--------- .../KeyStats/Views/FloatingStatsWindow.xaml.cs | 8 ++++---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index cf301a1..adf412f 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -3,8 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:p="clr-namespace:KeyStats.Properties" Title="{x:Static p:Strings.FloatingStats_WindowTitle}" - Width="104" - Height="36" + Width="72" + Height="28" WindowStyle="None" AllowsTransparency="False" Background="Transparent" @@ -19,15 +19,15 @@ Background="{DynamicResource TrayBackdropTintBrush}" BorderBrush="{DynamicResource TrayPopupBorderBrush}" BorderThickness="1" - CornerRadius="7" + CornerRadius="6" Cursor="SizeAll" SnapsToDevicePixels="True" MouseLeftButtonDown="RootBorder_MouseLeftButtonDown"> - + - + @@ -44,7 +44,7 @@ @@ -61,10 +61,10 @@ ToolTip="{Binding SecondaryFullValue}"/> - + - + @@ -80,7 +80,7 @@ ToolTip="{Binding PrimaryFullValue}"/> Date: Sun, 23 Aug 2026 13:31:36 +0800 Subject: [PATCH 10/20] refactor: remove double-row side spacing --- KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml | 4 ++-- KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index adf412f..f165718 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -61,7 +61,7 @@ ToolTip="{Binding SecondaryFullValue}"/> - + @@ -80,7 +80,7 @@ ToolTip="{Binding PrimaryFullValue}"/> Date: Sun, 23 Aug 2026 13:34:24 +0800 Subject: [PATCH 11/20] feat: add floating stats quick actions --- .../Views/FloatingStatsWindow.xaml.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs index cb4bafc..15304d7 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs @@ -155,6 +155,38 @@ private void RootBorder_MouseLeftButtonDown(object sender, MouseButtonEventArgs 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 @@ -165,6 +197,10 @@ private ContextMenu BuildContextMenu() App.CurrentApp?.ShowSettingsWindow(); }; menu.Items.Add(settingsItem); + menu.Opened += (_, _) => + { + lockPositionItem.IsChecked = StatsManager.Instance.Settings.FloatingStatsPositionLocked; + }; return menu; } From 35089062861836bf52dc899bb70bf002dcfaf522 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 13:43:55 +0800 Subject: [PATCH 12/20] refactor: remove taskbar stats integration --- KeyStats.Windows/KeyStats/App.xaml.cs | 86 ---- .../KeyStats/Helpers/NativeInterop.cs | 75 ---- .../KeyStats/Helpers/TaskbarStatsHost.cs | 264 ------------ .../KeyStats/Models/AppSettings.cs | 3 - .../KeyStats/Properties/Strings.cs | 7 - .../KeyStats/Properties/Strings.resx | 7 - .../KeyStats/Properties/Strings.zh-Hans.resx | 7 - .../KeyStats/Properties/Strings.zh-Hant.resx | 7 - .../KeyStats/Views/SettingsWindow.xaml | 21 - .../KeyStats/Views/SettingsWindow.xaml.cs | 34 -- .../Views/TaskbarStatsNativeControl.cs | 393 ------------------ 11 files changed, 904 deletions(-) delete mode 100644 KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs delete mode 100644 KeyStats.Windows/KeyStats/Views/TaskbarStatsNativeControl.cs diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index a71fc59..8abe7db 100644 --- a/KeyStats.Windows/KeyStats/App.xaml.cs +++ b/KeyStats.Windows/KeyStats/App.xaml.cs @@ -29,7 +29,6 @@ public partial class App : System.Windows.Application private TrayIconViewModel? _trayIconViewModel; private TrayContextMenuHost? _trayContextMenuHost; private TaskbarCreatedWatcher? _taskbarCreatedWatcher; - private TaskbarStatsHost? _taskbarStatsHost; private SettingsWindow? _settingsWindow; private NotificationSettingsWindow? _notificationSettingsWindow; private MouseCalibrationWindow? _mouseCalibrationWindow; @@ -39,13 +38,11 @@ public partial class App : System.Windows.Application private SyncSettingsWindow? _syncSettingsWindow; private FloatingStatsWindow? _floatingStatsWindow; private MenuItem? _floatingStatsMenuItem; - private MenuItem? _taskbarStatsMenuItem; private System.Threading.Mutex? _singleInstanceMutex; private string? _appVersion; private IPostHogAnalytics? _postHogClient; private SyncCoordinator? _syncCoordinator; private long _lastResumeRecoveryTicks; - private bool _taskbarStatsPageviewTracked; protected override void OnStartup(StartupEventArgs e) { @@ -127,7 +124,6 @@ protected override void OnStartup(StartupEventArgs e) Console.WriteLine("Creating tray icon..."); _trayIconViewModel = new TrayIconViewModel(); _trayIconViewModel.PropertyChanged += OnTrayIconViewModelPropertyChanged; - _taskbarStatsHost = new TaskbarStatsHost(); _taskbarCreatedWatcher = new TaskbarCreatedWatcher(() => { Dispatcher.BeginInvoke(new Action(() => @@ -185,19 +181,6 @@ private System.Windows.Controls.ContextMenu CreateContextMenu() }; menu.Items.Add(_floatingStatsMenuItem); - _taskbarStatsMenuItem = new System.Windows.Controls.MenuItem - { - Header = KeyStats.Properties.Strings.Tray_TaskbarStats, - IsCheckable = true, - IsChecked = StatsManager.Instance.Settings.TaskbarStatsEnabled - }; - _taskbarStatsMenuItem.Click += (s, e) => - { - var menuItem = (System.Windows.Controls.MenuItem)s!; - SetTaskbarStatsEnabled(menuItem.IsChecked, "tray_context_menu"); - }; - menu.Items.Add(_taskbarStatsMenuItem); - var settingsItem = new System.Windows.Controls.MenuItem { Header = KeyStats.Properties.Strings.Tray_Settings }; settingsItem.Click += (s, e) => { @@ -578,8 +561,6 @@ protected override void OnExit(ExitEventArgs e) } TrackAnalyticsExit(); _trayIconViewModel?.Cleanup(); - _taskbarStatsHost?.Dispose(); - _taskbarStatsHost = null; _trayContextMenuHost?.Dispose(); _taskbarCreatedWatcher?.Dispose(); _taskbarCreatedWatcher = null; @@ -1002,56 +983,6 @@ public void TrackClick(string elementName, Dictionary? extraPro /// public static App? CurrentApp => Current as App; public SyncCoordinator? SyncCoordinator => _syncCoordinator; - public event Action? TaskbarStatsVisibilityChanged; - - public void SetTaskbarStatsEnabled(bool enabled, string source) - { - if (!Dispatcher.CheckAccess()) - { - Dispatcher.BeginInvoke(new Action(() => SetTaskbarStatsEnabled(enabled, source))); - return; - } - - var settings = StatsManager.Instance.Settings; - var changed = settings.TaskbarStatsEnabled != enabled; - settings.TaskbarStatsEnabled = enabled; - if (changed) - { - StatsManager.Instance.SaveSettings(); - } - - _taskbarStatsHost ??= new TaskbarStatsHost(); - _taskbarStatsHost.SetEnabled(enabled); - if (_taskbarStatsMenuItem != null) - { - _taskbarStatsMenuItem.IsChecked = enabled; - } - if (enabled) - { - TrackTaskbarStatsPageViewOnce(); - } - - if (changed) - { - TrackClick("taskbar_stats_visibility", new Dictionary - { - ["enabled"] = enabled, - ["source"] = source - }); - TaskbarStatsVisibilityChanged?.Invoke(enabled); - } - } - - private void TrackTaskbarStatsPageViewOnce() - { - if (_taskbarStatsPageviewTracked) - { - return; - } - - _taskbarStatsPageviewTracked = true; - TrackPageView("taskbar_stats"); - } private void RegisterSystemEventHandlers() { @@ -1180,23 +1111,6 @@ private void RecreateTrayIntegration() }; _trayIcon.MouseClick += OnTrayIconMouseClick; - var taskbarStatsEnabled = StatsManager.Instance.Settings.TaskbarStatsEnabled; - if (taskbarStatsEnabled) - { - if (_taskbarStatsHost?.IsEnabled == true) - { - _taskbarStatsHost.Recreate(); - } - else - { - _taskbarStatsHost?.SetEnabled(true); - } - TrackTaskbarStatsPageViewOnce(); - } - else - { - _taskbarStatsHost?.SetEnabled(false); - } } private void OnTrayIconMouseClick(object? sender, Forms.MouseEventArgs e) diff --git a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs index 6b2e0ed..1f28fe7 100644 --- a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs +++ b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs @@ -27,24 +27,6 @@ public static class NativeInterop public const int XBUTTON1 = 0x0001; // Back public const int XBUTTON2 = 0x0002; // Forward - public const int WS_CHILD = unchecked((int)0x40000000); - public const int WS_VISIBLE = 0x10000000; - public const int WS_CLIPSIBLINGS = 0x04000000; - public const int WS_CLIPCHILDREN = 0x02000000; - public const int WS_POPUP = unchecked((int)0x80000000); - - public const int WS_EX_TOOLWINDOW = 0x00000080; - public const int WS_EX_NOACTIVATE = 0x08000000; - public const int WS_EX_NOPARENTNOTIFY = 0x00000004; - - public const uint SWP_NOSIZE = 0x0001; - public const uint SWP_NOMOVE = 0x0002; - public const uint SWP_NOACTIVATE = 0x0010; - public const uint SWP_SHOWWINDOW = 0x0040; - - public static readonly IntPtr HWND_TOP = IntPtr.Zero; - public static readonly IntPtr HWND_TOPMOST = new(-1); - public const int VK_SHIFT = 0x10; public const int VK_CONTROL = 0x11; public const int VK_MENU = 0x12; // Alt key @@ -128,45 +110,6 @@ public struct MARGINS [return: MarshalAs(UnmanagedType.Bool)] public static extern bool DestroyIcon(IntPtr hIcon); - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - public static extern IntPtr FindWindow(string? lpClassName, string? lpWindowName); - - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - public static extern IntPtr FindWindowEx( - IntPtr hWndParent, - IntPtr hWndChildAfter, - string? lpszClass, - string? lpszWindow); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect); - - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool IsWindow(IntPtr hWnd); - - [DllImport("user32.dll")] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool IsWindowVisible(IntPtr hWnd); - - [DllImport("user32.dll")] - public static extern IntPtr GetParent(IntPtr hWnd); - - [DllImport("user32.dll", SetLastError = true)] - [return: MarshalAs(UnmanagedType.Bool)] - public static extern bool SetWindowPos( - IntPtr hWnd, - IntPtr hWndInsertAfter, - int x, - int y, - int cx, - int cy, - uint uFlags); - - [DllImport("user32.dll")] - private static extern uint GetDpiForWindow(IntPtr hWnd); - [DllImport("shell32.dll", SetLastError = true)] public static extern int Shell_NotifyIconGetRect(ref NOTIFYICONIDENTIFIER identifier, out RECT iconLocation); @@ -188,24 +131,6 @@ public static bool IsKeyDown(int vkCode) return (GetAsyncKeyState(vkCode) & 0x8000) != 0; } - public static uint TryGetDpiForWindow(IntPtr hWnd) - { - if (hWnd == IntPtr.Zero) - { - return 96; - } - - try - { - var dpi = GetDpiForWindow(hWnd); - return dpi == 0 ? 96 : dpi; - } - catch (EntryPointNotFoundException) - { - return 96; - } - } - public static short HiWord(int dword) { return (short)(dword >> 16); diff --git a/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs b/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs deleted file mode 100644 index 55d8e09..0000000 --- a/KeyStats.Windows/KeyStats/Helpers/TaskbarStatsHost.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System; -using System.Windows.Threading; -using KeyStats.Views; - -namespace KeyStats.Helpers; - -/// -/// Hosts the compact statistics control as a native child of the primary taskbar. -/// -public sealed class TaskbarStatsHost : IDisposable -{ - private const int BaseWidth = 142; - private const int BaseHeight = 40; - private const int BaseEdgeInset = 2; - private const int BaseFallbackNotificationWidth = 96; - - private readonly DispatcherTimer _positionTimer; - private TaskbarStatsNativeControl? _control; - private IntPtr _taskbarHandle; - private bool _enabled; - private bool _isDisposed; - - public bool IsEnabled => _enabled; - - public TaskbarStatsHost() - { - _positionTimer = new DispatcherTimer(DispatcherPriority.Background) - { - Interval = TimeSpan.FromSeconds(1) - }; - _positionTimer.Tick += OnPositionTimerTick; - } - - public void SetEnabled(bool enabled) - { - if (_isDisposed || _enabled == enabled) - { - return; - } - - _enabled = enabled; - if (!enabled) - { - _positionTimer.Stop(); - DestroyControl(); - return; - } - - EnsureHost(); - _positionTimer.Start(); - } - - public void Recreate() - { - if (_isDisposed || !_enabled) - { - return; - } - - DestroyControl(); - EnsureHost(); - } - - public void Dispose() - { - if (_isDisposed) - { - return; - } - - _isDisposed = true; - _positionTimer.Stop(); - _positionTimer.Tick -= OnPositionTimerTick; - DestroyControl(); - } - - private void OnPositionTimerTick(object? sender, EventArgs e) - { - EnsureHost(); - } - - private void EnsureHost() - { - if (!_enabled || _isDisposed) - { - return; - } - - var taskbar = NativeInterop.FindWindow("Shell_TrayWnd", null); - if (taskbar == IntPtr.Zero || !NativeInterop.IsWindow(taskbar)) - { - DestroyControl(); - return; - } - - var controlHandle = GetControlHandle(); - var parentChanged = _taskbarHandle != IntPtr.Zero && _taskbarHandle != taskbar; - var nativeParentChanged = controlHandle != IntPtr.Zero && - NativeInterop.GetParent(controlHandle) != taskbar; - if (controlHandle == IntPtr.Zero || parentChanged || nativeParentChanged) - { - DestroyControl(); - TryCreateControl(taskbar); - return; - } - - if (TryGetPlacement(taskbar, out var placement)) - { - ApplyPlacement(placement); - } - } - - private void TryCreateControl(IntPtr taskbar) - { - if (!TryGetPlacement(taskbar, out var placement)) - { - return; - } - - TaskbarStatsNativeControl? control = null; - try - { - control = new TaskbarStatsNativeControl(); - control.CreateInTaskbar(taskbar); - _taskbarHandle = taskbar; - _control = control; - ApplyPlacement(placement); - } - catch (Exception ex) - { - Console.WriteLine($"Taskbar stats native window creation failed: {ex.Message}"); - control?.Dispose(); - _control = null; - _taskbarHandle = IntPtr.Zero; - } - } - - private void ApplyPlacement(Placement placement) - { - var handle = GetControlHandle(); - if (handle == IntPtr.Zero) - { - return; - } - - _control?.SetCompactMode(placement.Compact); - NativeInterop.SetWindowPos( - handle, - NativeInterop.HWND_TOP, - placement.RelativeX, - placement.RelativeY, - placement.Width, - placement.Height, - NativeInterop.SWP_NOACTIVATE | NativeInterop.SWP_SHOWWINDOW); - } - - private static bool TryGetPlacement(IntPtr taskbar, out Placement placement) - { - placement = default; - if (!NativeInterop.GetWindowRect(taskbar, out var taskbarRect)) - { - return false; - } - - var taskbarWidth = taskbarRect.Right - taskbarRect.Left; - var taskbarHeight = taskbarRect.Bottom - taskbarRect.Top; - if (taskbarWidth <= 0 || taskbarHeight <= 0) - { - return false; - } - - var dpi = NativeInterop.TryGetDpiForWindow(taskbar); - var scale = dpi / 96.0; - var edgeInset = Math.Max(1, Scale(BaseEdgeInset, scale)); - var horizontal = taskbarWidth >= taskbarHeight; - var notify = NativeInterop.FindWindowEx(taskbar, IntPtr.Zero, "TrayNotifyWnd", null); - NativeInterop.RECT notifyRect = default; - var hasNotifyRect = notify != IntPtr.Zero && NativeInterop.GetWindowRect(notify, out notifyRect); - - int width; - int height; - int relativeX; - int relativeY; - bool compact; - - if (horizontal) - { - width = Math.Min(Scale(BaseWidth, scale), Math.Max(1, taskbarWidth - edgeInset * 2)); - height = Math.Min(Scale(BaseHeight, scale), Math.Max(1, taskbarHeight - edgeInset * 2)); - var notifyLeft = hasNotifyRect - ? notifyRect.Left - taskbarRect.Left - : taskbarWidth - Scale(BaseFallbackNotificationWidth, scale); - relativeX = Math.Max(edgeInset, notifyLeft - width - edgeInset); - relativeY = Math.Max(edgeInset, (taskbarHeight - height) / 2); - compact = false; - } - else - { - width = Math.Max(1, taskbarWidth - edgeInset * 2); - height = Math.Min(Scale(BaseHeight, scale), Math.Max(1, taskbarHeight - edgeInset * 2)); - var notifyTop = hasNotifyRect - ? notifyRect.Top - taskbarRect.Top - : taskbarHeight - Scale(BaseFallbackNotificationWidth, scale); - relativeX = edgeInset; - relativeY = Math.Max(edgeInset, notifyTop - height - edgeInset); - compact = true; - } - - placement = new Placement(relativeX, relativeY, width, height, compact); - return true; - } - - private static int Scale(int value, double scale) - { - return Math.Max(1, (int)Math.Round(value * scale, MidpointRounding.AwayFromZero)); - } - - private IntPtr GetControlHandle() - { - if (_control == null || !_control.IsHandleCreated) - { - return IntPtr.Zero; - } - - var handle = _control.Handle; - return handle != IntPtr.Zero && NativeInterop.IsWindow(handle) ? handle : IntPtr.Zero; - } - - private void DestroyControl() - { - if (_control != null) - { - try - { - _control.Dispose(); - } - catch (InvalidOperationException) - { - // Explorer may have already destroyed the cross-process child HWND. - } - _control = null; - } - - _taskbarHandle = IntPtr.Zero; - } - - private readonly struct Placement - { - public Placement(int relativeX, int relativeY, int width, int height, bool compact) - { - RelativeX = relativeX; - RelativeY = relativeY; - Width = width; - Height = height; - Compact = compact; - } - - public int RelativeX { get; } - public int RelativeY { get; } - public int Width { get; } - public int Height { get; } - public bool Compact { get; } - } -} diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index 5bb22aa..9f9a269 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -84,9 +84,6 @@ public class AppSettings [JsonPropertyName("floatingStatsPositionLocked")] public bool FloatingStatsPositionLocked { get; set; } - [JsonPropertyName("taskbarStatsEnabled")] - public bool TaskbarStatsEnabled { 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 cc27213..3b5b9e4 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.cs +++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs @@ -30,7 +30,6 @@ public static class Strings public static string Tray_OpenMainWindow => Get(nameof(Tray_OpenMainWindow)); public static string Tray_ShowFloatingStats => Get(nameof(Tray_ShowFloatingStats)); - public static string Tray_TaskbarStats => Get(nameof(Tray_TaskbarStats)); 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)); @@ -76,8 +75,6 @@ public static class Strings 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 Settings_TaskbarStats => Get(nameof(Settings_TaskbarStats)); - public static string Settings_TaskbarStatsDesc => Get(nameof(Settings_TaskbarStatsDesc)); public static string Sync_WindowTitle => Get(nameof(Sync_WindowTitle)); public static string Sync_HeaderTitle => Get(nameof(Sync_HeaderTitle)); @@ -181,10 +178,6 @@ public static class Strings 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 TaskbarStats_OpenDetails => Get(nameof(TaskbarStats_OpenDetails)); - public static string TaskbarStats_PrimaryMetric => Get(nameof(TaskbarStats_PrimaryMetric)); - public static string TaskbarStats_SecondaryMetric => Get(nameof(TaskbarStats_SecondaryMetric)); - public static string TaskbarStats_Hide => Get(nameof(TaskbarStats_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 bd67115..6a1bede 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx @@ -69,7 +69,6 @@ Open Main Window Show Today's Floating Stats - Show Taskbar Stats Settings Start at Login Key History @@ -86,12 +85,6 @@ Lock Position Open Detailed Stats Hide Floating Window - Open Detailed Stats - First Row - Second Row - Hide Taskbar Stats - Taskbar Stats - Show today's two selected metrics as a separate two-row taskbar component. Floating Stats Choose the two metrics shown in the desktop floating window and adjust its behavior. diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx index 0c4b198..6091ac5 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx @@ -69,7 +69,6 @@ 打开主界面 显示今日统计浮窗 - 显示任务栏双排统计 设置 开机启动 历史按键统计 @@ -86,12 +85,6 @@ 锁定位置 打开详细统计 隐藏浮窗 - 打开详细统计 - 第一行 - 第二行 - 隐藏任务栏统计 - 任务栏统计 - 在任务栏中以独立双排组件显示今日选中的两项统计。 统计浮窗 选择桌面浮窗显示的两项统计,并调整浮窗行为。 diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx index 5b44ea3..af16d48 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx @@ -69,7 +69,6 @@ 開啟主視窗 顯示今日統計浮窗 - 顯示工作列雙排統計 設定 登入時啟動 按鍵歷史 @@ -86,12 +85,6 @@ 鎖定位置 開啟詳細統計 隱藏浮窗 - 開啟詳細統計 - 第一列 - 第二列 - 隱藏工作列統計 - 工作列統計 - 在工作列中以獨立雙排元件顯示今日選取的兩項統計。 統計浮窗 選擇桌面浮窗顯示的兩項統計,並調整浮窗行為。 diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml index 7e96406..5c3722e 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml @@ -174,27 +174,6 @@ - - - - - - - - - - - - - - diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs index 47bd51d..94ef946 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs @@ -13,7 +13,6 @@ namespace KeyStats.Views; public partial class SettingsWindow : Window { private const string GitHubUrl = "https://github.com/debugtheworldbot/keyStats"; - private bool _isLoadingTaskbarStats = true; private bool _isLoadingFloatingStats = true; public SettingsWindow() @@ -29,13 +28,6 @@ private void OnLoaded(object sender, RoutedEventArgs e) { ApplyWindowBackdrop(); LoadFloatingStatsControls(); - _isLoadingTaskbarStats = true; - TaskbarStatsCheckBox.IsChecked = StatsManager.Instance.Settings.TaskbarStatsEnabled; - _isLoadingTaskbarStats = false; - if (App.CurrentApp != null) - { - App.CurrentApp.TaskbarStatsVisibilityChanged += OnTaskbarStatsVisibilityChanged; - } if (App.CurrentApp?.SyncCoordinator != null) { App.CurrentApp.SyncCoordinator.StatusChanged += OnSyncStatusChanged; @@ -47,10 +39,6 @@ private void OnLoaded(object sender, RoutedEventArgs e) private void OnClosed(object? sender, System.EventArgs e) { ThemeManager.Instance.ThemeChanged -= OnThemeChanged; - if (App.CurrentApp != null) - { - App.CurrentApp.TaskbarStatsVisibilityChanged -= OnTaskbarStatsVisibilityChanged; - } if (App.CurrentApp?.SyncCoordinator != null) { App.CurrentApp.SyncCoordinator.StatusChanged -= OnSyncStatusChanged; @@ -281,28 +269,6 @@ private void FloatingStatsBehavior_Changed(object sender, RoutedEventArgs e) }); } - private void TaskbarStatsVisibility_Changed(object sender, RoutedEventArgs e) - { - if (_isLoadingTaskbarStats) - { - return; - } - - App.CurrentApp?.SetTaskbarStatsEnabled( - TaskbarStatsCheckBox.IsChecked == true, - "settings"); - } - - private void OnTaskbarStatsVisibilityChanged(bool enabled) - { - Dispatcher.BeginInvoke(new System.Action(() => - { - _isLoadingTaskbarStats = true; - TaskbarStatsCheckBox.IsChecked = enabled; - _isLoadingTaskbarStats = false; - })); - } - private void MouseCalibration_Click(object sender, RoutedEventArgs e) { App.CurrentApp?.TrackClick("open_mouse_calibration"); diff --git a/KeyStats.Windows/KeyStats/Views/TaskbarStatsNativeControl.cs b/KeyStats.Windows/KeyStats/Views/TaskbarStatsNativeControl.cs deleted file mode 100644 index cd06912..0000000 --- a/KeyStats.Windows/KeyStats/Views/TaskbarStatsNativeControl.cs +++ /dev/null @@ -1,393 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Drawing; -using KeyStats.Helpers; -using KeyStats.ViewModels; -using Forms = System.Windows.Forms; - -namespace KeyStats.Views; - -/// -/// GDI-rendered taskbar child window for compatibility with the Windows 11 taskbar compositor. -/// -public sealed class TaskbarStatsNativeControl : Forms.Control -{ - private const int WM_MOUSEACTIVATE = 0x0021; - private const int MA_NOACTIVATE = 3; - - private readonly FloatingStatsViewModel _viewModel; - private readonly Forms.ToolTip _toolTip; - private readonly Dictionary _primaryMetricItems = - new(StringComparer.Ordinal); - private readonly Dictionary _secondaryMetricItems = - new(StringComparer.Ordinal); - private IntPtr _taskbarParent; - private bool _compact; - private bool _hovered; - private bool _isDisposed; - - public TaskbarStatsNativeControl() - { - SetStyle( - Forms.ControlStyles.UserPaint | - Forms.ControlStyles.AllPaintingInWmPaint | - Forms.ControlStyles.OptimizedDoubleBuffer | - Forms.ControlStyles.ResizeRedraw | - Forms.ControlStyles.Opaque, - true); - - TabStop = false; - _viewModel = new FloatingStatsViewModel(); - _viewModel.PropertyChanged += OnViewModelPropertyChanged; - _toolTip = new Forms.ToolTip - { - ShowAlways = true, - InitialDelay = 450, - ReshowDelay = 100 - }; - ContextMenuStrip = CreateContextMenu(); - ThemeManager.Instance.ThemeChanged += OnThemeChanged; - UpdateToolTip(); - } - - public void CreateInTaskbar(IntPtr taskbarParent) - { - if (_isDisposed) - { - throw new ObjectDisposedException(nameof(TaskbarStatsNativeControl)); - } - - if (taskbarParent == IntPtr.Zero) - { - throw new ArgumentException("A valid taskbar HWND is required.", nameof(taskbarParent)); - } - - _taskbarParent = taskbarParent; - CreateControl(); - if (!IsHandleCreated) - { - throw new InvalidOperationException("The native taskbar statistics HWND was not created."); - } - } - - public void SetCompactMode(bool compact) - { - if (_compact == compact) - { - return; - } - - _compact = compact; - Invalidate(); - } - - protected override Forms.CreateParams CreateParams - { - get - { - var parameters = base.CreateParams; - parameters.Caption = "KeyStatsTaskbarStatsWindow"; - parameters.Parent = _taskbarParent; - parameters.Style = NativeInterop.WS_CHILD | - NativeInterop.WS_VISIBLE | - NativeInterop.WS_CLIPSIBLINGS | - NativeInterop.WS_CLIPCHILDREN; - parameters.ExStyle = NativeInterop.WS_EX_TOOLWINDOW | - NativeInterop.WS_EX_NOACTIVATE | - NativeInterop.WS_EX_NOPARENTNOTIFY; - return parameters; - } - } - - protected override void OnPaintBackground(Forms.PaintEventArgs e) - { - e.Graphics.Clear(GetPalette().Background); - } - - protected override void OnPaint(Forms.PaintEventArgs e) - { - base.OnPaint(e); - - var palette = GetPalette(); - e.Graphics.Clear(_hovered ? palette.HoverBackground : palette.Background); - e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; - - // Use the actual HWND height instead of DeviceDpi. Explorer and WinForms can - // report different DPI contexts for a cross-process taskbar child on Windows 11. - var scale = Math.Max(0.75f, ClientSize.Height / 40f); - var firstRowHeight = ClientSize.Height / 2; - var secondRowHeight = ClientSize.Height - firstRowHeight; - using var labelFont = new Font( - "Microsoft YaHei UI", - Math.Max(9f, 10f * scale), - FontStyle.Regular, - GraphicsUnit.Pixel); - using var valueFont = new Font( - "Microsoft YaHei UI", - Math.Max(10f, 11f * scale), - FontStyle.Bold, - GraphicsUnit.Pixel); - using var dividerPen = new Pen(palette.Divider, Math.Max(1f, scale * 0.5f)); - - DrawRow(e.Graphics, new Rectangle(0, 0, ClientSize.Width, firstRowHeight), - _viewModel.PrimaryLabel, _viewModel.PrimaryValue, labelFont, valueFont, palette, scale); - - var dividerY = Math.Max(0, firstRowHeight - 1); - e.Graphics.DrawLine(dividerPen, 0, dividerY, ClientSize.Width, dividerY); - - DrawRow(e.Graphics, new Rectangle(0, firstRowHeight, ClientSize.Width, secondRowHeight), - _viewModel.SecondaryLabel, _viewModel.SecondaryValue, labelFont, valueFont, palette, scale); - } - - protected override void OnMouseEnter(EventArgs e) - { - base.OnMouseEnter(e); - _hovered = true; - Invalidate(); - } - - protected override void OnMouseLeave(EventArgs e) - { - base.OnMouseLeave(e); - _hovered = false; - Invalidate(); - } - - protected override void OnMouseDoubleClick(Forms.MouseEventArgs e) - { - base.OnMouseDoubleClick(e); - if (e.Button != Forms.MouseButtons.Left) - { - return; - } - - App.CurrentApp?.TrackClick("taskbar_stats_open_details"); - App.CurrentApp?.ShowMainWindow(); - } - - protected override void WndProc(ref Forms.Message message) - { - if (message.Msg == WM_MOUSEACTIVATE) - { - message.Result = new IntPtr(MA_NOACTIVATE); - return; - } - - base.WndProc(ref message); - } - - protected override void Dispose(bool disposing) - { - if (disposing && !_isDisposed) - { - _isDisposed = true; - ThemeManager.Instance.ThemeChanged -= OnThemeChanged; - _viewModel.PropertyChanged -= OnViewModelPropertyChanged; - _viewModel.Cleanup(); - _toolTip.Dispose(); - ContextMenuStrip?.Dispose(); - ContextMenuStrip = null; - } - - base.Dispose(disposing); - } - - private void DrawRow( - Graphics graphics, - Rectangle bounds, - string label, - string value, - Font labelFont, - Font valueFont, - Palette palette, - float scale) - { - var horizontalPadding = Math.Max(4, (int)Math.Round(6 * scale)); - var markerSize = Math.Max(3, (int)Math.Round(3 * scale)); - var markerX = horizontalPadding; - var markerY = bounds.Top + Math.Max(0, (bounds.Height - markerSize) / 2); - using var markerBrush = new SolidBrush(palette.Accent); - graphics.FillEllipse(markerBrush, markerX, markerY, markerSize, markerSize); - - var contentLeft = markerX + markerSize + Math.Max(3, (int)Math.Round(4 * scale)); - var contentRight = Math.Max(contentLeft, bounds.Right - horizontalPadding); - var textFlags = Forms.TextFormatFlags.NoPrefix | - Forms.TextFormatFlags.NoPadding | - Forms.TextFormatFlags.SingleLine | - Forms.TextFormatFlags.VerticalCenter | - Forms.TextFormatFlags.EndEllipsis; - - if (_compact) - { - Forms.TextRenderer.DrawText(graphics, value, valueFont, - Rectangle.FromLTRB(contentLeft, bounds.Top, contentRight, bounds.Bottom), - palette.PrimaryText, textFlags | Forms.TextFormatFlags.Right); - return; - } - - var measuredValue = Forms.TextRenderer.MeasureText( - graphics, value, valueFont, new Size(int.MaxValue, bounds.Height), textFlags).Width; - var minimumValueWidth = Math.Max(38, (int)Math.Round(46 * scale)); - var valueWidth = Math.Min( - Math.Max(minimumValueWidth, measuredValue), - Math.Max(minimumValueWidth, (contentRight - contentLeft) / 2)); - var valueLeft = Math.Max(contentLeft, contentRight - valueWidth); - var labelRight = Math.Max(contentLeft, valueLeft - Math.Max(3, (int)Math.Round(5 * scale))); - - Forms.TextRenderer.DrawText(graphics, label, labelFont, - Rectangle.FromLTRB(contentLeft, bounds.Top, labelRight, bounds.Bottom), - palette.SecondaryText, textFlags); - Forms.TextRenderer.DrawText(graphics, value, valueFont, - Rectangle.FromLTRB(valueLeft, bounds.Top, contentRight, bounds.Bottom), - palette.PrimaryText, textFlags | Forms.TextFormatFlags.Right); - } - - private Forms.ContextMenuStrip CreateContextMenu() - { - var menu = new Forms.ContextMenuStrip(); - menu.Opening += (_, _) => RefreshMetricMenuState(); - - var openDetailsItem = new Forms.ToolStripMenuItem(KeyStats.Properties.Strings.TaskbarStats_OpenDetails); - openDetailsItem.Click += (_, _) => - { - App.CurrentApp?.TrackClick("taskbar_stats_open_details"); - App.CurrentApp?.ShowMainWindow(); - }; - menu.Items.Add(openDetailsItem); - menu.Items.Add(new Forms.ToolStripSeparator()); - - var primaryItem = new Forms.ToolStripMenuItem(KeyStats.Properties.Strings.TaskbarStats_PrimaryMetric); - PopulateMetricMenu(primaryItem, isPrimary: true, _primaryMetricItems); - menu.Items.Add(primaryItem); - - var secondaryItem = new Forms.ToolStripMenuItem(KeyStats.Properties.Strings.TaskbarStats_SecondaryMetric); - PopulateMetricMenu(secondaryItem, isPrimary: false, _secondaryMetricItems); - menu.Items.Add(secondaryItem); - menu.Items.Add(new Forms.ToolStripSeparator()); - - var hideItem = new Forms.ToolStripMenuItem(KeyStats.Properties.Strings.TaskbarStats_Hide); - hideItem.Click += (_, _) => System.Windows.Application.Current?.Dispatcher.BeginInvoke(new Action(() => - App.CurrentApp?.SetTaskbarStatsEnabled(false, "taskbar_stats_context_menu"))); - menu.Items.Add(hideItem); - return menu; - } - - private void PopulateMetricMenu( - Forms.ToolStripMenuItem parent, - bool isPrimary, - IDictionary destination) - { - foreach (var metricId in FloatingStatsViewModel.AvailableMetricIds) - { - var capturedMetricId = metricId; - var item = new Forms.ToolStripMenuItem(FloatingStatsViewModel.GetMetricLabel(metricId)); - item.Click += (_, _) => SelectMetric(isPrimary, capturedMetricId); - destination[metricId] = item; - parent.DropDownItems.Add(item); - } - } - - private void RefreshMetricMenuState() - { - var primaryMetric = _viewModel.PrimaryMetricId; - var secondaryMetric = _viewModel.SecondaryMetricId; - - foreach (var pair in _primaryMetricItems) - { - pair.Value.Checked = string.Equals(pair.Key, primaryMetric, StringComparison.Ordinal); - pair.Value.Enabled = !string.Equals(pair.Key, secondaryMetric, StringComparison.Ordinal); - } - - foreach (var pair in _secondaryMetricItems) - { - pair.Value.Checked = string.Equals(pair.Key, secondaryMetric, StringComparison.Ordinal); - pair.Value.Enabled = !string.Equals(pair.Key, primaryMetric, StringComparison.Ordinal); - } - } - - private void SelectMetric(bool isPrimary, string metricId) - { - var currentMetric = isPrimary ? _viewModel.PrimaryMetricId : _viewModel.SecondaryMetricId; - if (string.Equals(currentMetric, metricId, StringComparison.Ordinal)) - { - return; - } - - _viewModel.SetMetric(isPrimary, metricId); - App.CurrentApp?.TrackClick("taskbar_stats_metric_change", new Dictionary - { - ["row"] = isPrimary ? "primary" : "secondary", - ["metric"] = metricId - }); - } - - private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) - { - if (_isDisposed) - { - return; - } - - UpdateToolTip(); - Invalidate(); - } - - private void OnThemeChanged() - { - if (_isDisposed) - { - return; - } - - if (IsHandleCreated && InvokeRequired) - { - BeginInvoke(new Action(Invalidate)); - return; - } - - Invalidate(); - } - - private void UpdateToolTip() - { - _toolTip.SetToolTip( - this, - $"{_viewModel.PrimaryFullValue}{Environment.NewLine}{_viewModel.SecondaryFullValue}"); - } - - private static Palette GetPalette() - { - if (ThemeManager.Instance.IsDarkTheme) - { - return new Palette( - Color.FromArgb(32, 32, 32), Color.FromArgb(45, 45, 45), Color.White, - Color.FromArgb(197, 197, 197), Color.FromArgb(0, 120, 212), Color.FromArgb(61, 61, 61)); - } - - return new Palette( - Color.FromArgb(250, 250, 250), Color.FromArgb(238, 238, 238), Color.FromArgb(26, 26, 26), - Color.FromArgb(74, 74, 74), Color.FromArgb(0, 103, 192), Color.FromArgb(229, 229, 229)); - } - - private readonly struct Palette - { - public Palette(Color background, Color hoverBackground, Color primaryText, - Color secondaryText, Color accent, Color divider) - { - Background = background; - HoverBackground = hoverBackground; - PrimaryText = primaryText; - SecondaryText = secondaryText; - Accent = accent; - Divider = divider; - } - - public Color Background { get; } - public Color HoverBackground { get; } - public Color PrimaryText { get; } - public Color SecondaryText { get; } - public Color Accent { get; } - public Color Divider { get; } - } -} From 9641b18519d2747cd67671c62be3f2a936c5bf87 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 13:44:51 +0800 Subject: [PATCH 13/20] feat: add floating stats font size setting --- .../KeyStats/Models/AppSettings.cs | 13 ++++++++++ .../KeyStats/Properties/Strings.cs | 1 + .../KeyStats/Properties/Strings.resx | 1 + .../KeyStats/Properties/Strings.zh-Hans.resx | 1 + .../KeyStats/Properties/Strings.zh-Hant.resx | 1 + .../KeyStats/Views/FloatingStatsWindow.xaml | 20 ++++++++------ .../Views/FloatingStatsWindow.xaml.cs | 11 ++++++++ .../KeyStats/Views/SettingsWindow.xaml | 13 ++++++++++ .../KeyStats/Views/SettingsWindow.xaml.cs | 26 +++++++++++++++++++ 9 files changed, 79 insertions(+), 8 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index 9f9a269..0878fd4 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -8,6 +8,10 @@ public class AppSettings public const double DefaultMouseMetersPerPixel = 0.00005; public const string FloatingStatsSingleRowLayoutMode = "singleRow"; public const string FloatingStatsDoubleRowLayoutMode = "doubleRow"; + public const int DefaultFloatingStatsFontSize = 11; + public const int MinimumFloatingStatsFontSize = 9; + public const int MaximumFloatingStatsFontSize = 13; + private int _floatingStatsFontSize = DefaultFloatingStatsFontSize; [JsonPropertyName("notificationsEnabled")] public bool NotificationsEnabled { get; set; } @@ -72,6 +76,15 @@ public class AppSettings [JsonPropertyName("floatingStatsLayoutMode")] public string FloatingStatsLayoutMode { get; set; } = FloatingStatsSingleRowLayoutMode; + [JsonPropertyName("floatingStatsFontSize")] + public int FloatingStatsFontSize + { + get => _floatingStatsFontSize; + set => _floatingStatsFontSize = Math.Max( + MinimumFloatingStatsFontSize, + Math.Min(MaximumFloatingStatsFontSize, value)); + } + [JsonPropertyName("floatingStatsLeft")] public double? FloatingStatsLeft { get; set; } diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.cs b/KeyStats.Windows/KeyStats/Properties/Strings.cs index 3b5b9e4..7a7355a 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.cs +++ b/KeyStats.Windows/KeyStats/Properties/Strings.cs @@ -172,6 +172,7 @@ public static class Strings 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)); diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.resx b/KeyStats.Windows/KeyStats/Properties/Strings.resx index 6a1bede..cab5616 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.resx @@ -79,6 +79,7 @@ First Metric Second Metric Display Layout + Font Size One Row Two Rows Always on Top diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx index 6091ac5..3c5d93c 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hans.resx @@ -79,6 +79,7 @@ 第一项 第二项 展示方式 + 字体大小 一排 两排 始终置顶 diff --git a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx index af16d48..d40783c 100644 --- a/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx +++ b/KeyStats.Windows/KeyStats/Properties/Strings.zh-Hant.resx @@ -79,6 +79,7 @@ 第一項 第二項 顯示方式 + 字型大小 單排 雙排 永遠置頂 diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index f165718..ab67edc 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -31,10 +31,11 @@ - - - - + @@ -158,6 +159,18 @@ + + + diff --git a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs index 94ef946..af4d09c 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs @@ -159,6 +159,9 @@ private void LoadFloatingStatsControls() .ToList(); FloatingPrimaryMetricComboBox.ItemsSource = options; FloatingSecondaryMetricComboBox.ItemsSource = options; + FloatingFontSizeComboBox.ItemsSource = Enumerable.Range( + AppSettings.MinimumFloatingStatsFontSize, + AppSettings.MaximumFloatingStatsFontSize - AppSettings.MinimumFloatingStatsFontSize + 1); RefreshFloatingStatsControls(); _isLoadingFloatingStats = false; } @@ -183,6 +186,7 @@ private void RefreshFloatingStatsControls() ?? FloatingLayoutComboBox.Items[0]; FloatingTopmostCheckBox.IsChecked = settings.FloatingStatsTopmost; FloatingLockPositionCheckBox.IsChecked = settings.FloatingStatsPositionLocked; + FloatingFontSizeComboBox.SelectedItem = settings.FloatingStatsFontSize; } private void FloatingMetric_SelectionChanged(object sender, SelectionChangedEventArgs e) @@ -269,6 +273,28 @@ private void FloatingStatsBehavior_Changed(object sender, RoutedEventArgs e) }); } + 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"); From d682c2c82d49c41519fe91514dcfdf7f8da5d3ef Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 13:59:07 +0800 Subject: [PATCH 14/20] feat: scale floating stats with font size --- KeyStats.Windows/KeyStats/Models/AppSettings.cs | 2 +- .../KeyStats/Views/FloatingStatsWindow.xaml.cs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index 0878fd4..4046400 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -10,7 +10,7 @@ public class AppSettings public const string FloatingStatsDoubleRowLayoutMode = "doubleRow"; public const int DefaultFloatingStatsFontSize = 11; public const int MinimumFloatingStatsFontSize = 9; - public const int MaximumFloatingStatsFontSize = 13; + public const int MaximumFloatingStatsFontSize = 22; private int _floatingStatsFontSize = DefaultFloatingStatsFontSize; [JsonPropertyName("notificationsEnabled")] diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs index b16aa9c..d3c5f9a 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs @@ -225,12 +225,16 @@ private void ApplyFontSettings() private bool ApplyLayoutSettings() { + var settings = StatsManager.Instance.Settings; var useDoubleRow = string.Equals( - StatsManager.Instance.Settings.FloatingStatsLayoutMode, + settings.FloatingStatsLayoutMode, AppSettings.FloatingStatsDoubleRowLayoutMode, StringComparison.Ordinal); - var targetWidth = useDoubleRow ? DoubleRowWidth : SingleRowWidth; - var targetHeight = useDoubleRow ? DoubleRowHeight : SingleRowHeight; + var layoutScale = settings.FloatingStatsFontSize / (double)AppSettings.DefaultFloatingStatsFontSize; + 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; From 4047881d81a2cb7106b514df3c3e0dded8f580a9 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 14:04:46 +0800 Subject: [PATCH 15/20] feat: hide floating stats in fullscreen --- KeyStats.Windows/KeyStats/App.xaml.cs | 72 +++++++++++++++++++ .../Helpers/FullscreenWindowDetector.cs | 62 ++++++++++++++++ .../KeyStats/Helpers/NativeInterop.cs | 40 +++++++++++ 3 files changed, 174 insertions(+) create mode 100644 KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs diff --git a/KeyStats.Windows/KeyStats/App.xaml.cs b/KeyStats.Windows/KeyStats/App.xaml.cs index 8abe7db..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; @@ -38,11 +39,13 @@ public partial class App : System.Windows.Application 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) { @@ -303,6 +306,16 @@ public void ShowFloatingStatsWindow() return; } + StartFloatingStatsVisibilityMonitor(); + if (FullscreenWindowDetector.IsForegroundWindowFullscreen()) + { + _isFloatingStatsHiddenForFullscreen = true; + _floatingStatsWindow?.Hide(); + return; + } + + _isFloatingStatsHiddenForFullscreen = false; + if (_floatingStatsWindow != null) { _floatingStatsWindow.ShowWindow(); @@ -340,10 +353,68 @@ public void SetFloatingStatsVisible(bool isVisible) 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()) @@ -554,6 +625,7 @@ private static string MakeExportFileName() protected override void OnExit(ExitEventArgs e) { + StopFloatingStatsVisibilityMonitor(); UnregisterSystemEventHandlers(); if (_trayIconViewModel != null) { diff --git a/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs b/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs new file mode 100644 index 0000000..2ba4ff3 --- /dev/null +++ b/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs @@ -0,0 +1,62 @@ +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.IsZoomed(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; + } + + return CoversMonitor(windowBounds, monitorInfo.rcMonitor); + } + + 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/NativeInterop.cs b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs index 1f28fe7..8a785c1 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 { @@ -144,6 +153,37 @@ 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", CharSet = CharSet.Auto, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi); + [DllImport("user32.dll", SetLastError = true)] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); From a92c023c018d8492baca7d124c4f67950e011409 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 14:16:28 +0800 Subject: [PATCH 16/20] fix: detect Bilibili fullscreen playback --- .../Helpers/FullscreenWindowDetector.cs | 23 +++++++++++++++++-- .../KeyStats/Helpers/NativeInterop.cs | 14 +++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs b/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs index 2ba4ff3..c143c3c 100644 --- a/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs +++ b/KeyStats.Windows/KeyStats/Helpers/FullscreenWindowDetector.cs @@ -18,7 +18,6 @@ public static bool IsForegroundWindowFullscreen() windowHandle == NativeInterop.GetShellWindow() || !NativeInterop.IsWindowVisible(windowHandle) || NativeInterop.IsIconic(windowHandle) || - NativeInterop.IsZoomed(windowHandle) || !NativeInterop.GetWindowRect(windowHandle, out var windowBounds)) { return false; @@ -41,7 +40,27 @@ public static bool IsForegroundWindowFullscreen() return false; } - return CoversMonitor(windowBounds, monitorInfo.rcMonitor); + 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) diff --git a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs index 8a785c1..131ca8d 100644 --- a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs +++ b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs @@ -122,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 { From 42b49d605b9a110013f7701630e30995f7f12275 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 14:23:06 +0800 Subject: [PATCH 17/20] style: refine floating stats material --- KeyStats.Windows/KeyStats/App.xaml | 2 ++ KeyStats.Windows/KeyStats/Helpers/ThemeManager.cs | 4 ++++ KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml | 7 +++---- .../KeyStats/Views/FloatingStatsWindow.xaml.cs | 3 +-- 4 files changed, 10 insertions(+), 6 deletions(-) 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/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/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index ab67edc..d5828f9 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -16,10 +16,9 @@ TextOptions.TextFormattingMode="Display" TextOptions.TextRenderingMode="ClearType"> diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs index d3c5f9a..6134b2f 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs @@ -116,8 +116,7 @@ private void ApplyBackdrop() NativeInterop.DwmSystemBackdropType.TransientWindow); RootBorder.SetResourceReference( Border.BackgroundProperty, - _isBackdropEnabled ? "TrayBackdropTintBrush" : "SurfaceBrush"); - RootBorder.SetResourceReference(Border.BorderBrushProperty, "TrayPopupBorderBrush"); + _isBackdropEnabled ? "FloatingStatsSurfaceBrush" : "SurfaceBrush"); } private void RootBorder_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) From 9a0bee8f26d7ca05a708cb2f217142ebee855fb1 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 14:36:47 +0800 Subject: [PATCH 18/20] fix: update floating stats defaults --- KeyStats.Windows/KeyStats/Models/AppSettings.cs | 5 +++-- .../KeyStats/Views/FloatingStatsWindow.xaml | 16 ++++++++-------- .../KeyStats/Views/FloatingStatsWindow.xaml.cs | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index 4046400..a2a075b 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -8,7 +8,8 @@ public class AppSettings public const double DefaultMouseMetersPerPixel = 0.00005; public const string FloatingStatsSingleRowLayoutMode = "singleRow"; public const string FloatingStatsDoubleRowLayoutMode = "doubleRow"; - public const int DefaultFloatingStatsFontSize = 11; + public const int FloatingStatsLayoutBaseFontSize = 11; + public const int DefaultFloatingStatsFontSize = 12; public const int MinimumFloatingStatsFontSize = 9; public const int MaximumFloatingStatsFontSize = 22; private int _floatingStatsFontSize = DefaultFloatingStatsFontSize; @@ -74,7 +75,7 @@ public class AppSettings public string FloatingStatsSecondaryMetric { get; set; } = "totalClicks"; [JsonPropertyName("floatingStatsLayoutMode")] - public string FloatingStatsLayoutMode { get; set; } = FloatingStatsSingleRowLayoutMode; + public string FloatingStatsLayoutMode { get; set; } = FloatingStatsDoubleRowLayoutMode; [JsonPropertyName("floatingStatsFontSize")] public int FloatingStatsFontSize diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index d5828f9..699487d 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -3,8 +3,8 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:p="clr-namespace:KeyStats.Properties" Title="{x:Static p:Strings.FloatingStats_WindowTitle}" - Width="72" - Height="28" + Width="35" + Height="41" WindowStyle="None" AllowsTransparency="False" Background="Transparent" @@ -23,7 +23,7 @@ SnapsToDevicePixels="True" MouseLeftButtonDown="RootBorder_MouseLeftButtonDown"> - + @@ -33,7 +33,7 @@ - + @@ -72,7 +72,7 @@ Date: Sun, 23 Aug 2026 14:52:18 +0800 Subject: [PATCH 19/20] fix: address floating stats review feedback --- .../KeyStats/Helpers/MonitorGeometryHelper.cs | 37 ++++++++ .../KeyStats/Helpers/NativeInterop.cs | 38 ++++++++ .../KeyStats/Models/AppSettings.cs | 3 + .../Views/FloatingStatsWindow.xaml.cs | 90 +++++++++++++++---- .../KeyStats/Views/SettingsWindow.xaml | 7 +- .../KeyStats/Views/SettingsWindow.xaml.cs | 28 ++++++ KeyStats.Windows/design-qa.md | 59 ++++-------- 7 files changed, 202 insertions(+), 60 deletions(-) create mode 100644 KeyStats.Windows/KeyStats/Helpers/MonitorGeometryHelper.cs 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 131ca8d..71476a8 100644 --- a/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs +++ b/KeyStats.Windows/KeyStats/Helpers/NativeInterop.cs @@ -194,10 +194,48 @@ public static short LoWord(int dword) [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/Models/AppSettings.cs b/KeyStats.Windows/KeyStats/Models/AppSettings.cs index a2a075b..d58f5e2 100644 --- a/KeyStats.Windows/KeyStats/Models/AppSettings.cs +++ b/KeyStats.Windows/KeyStats/Models/AppSettings.cs @@ -92,6 +92,9 @@ public int FloatingStatsFontSize [JsonPropertyName("floatingStatsTop")] public double? FloatingStatsTop { get; set; } + [JsonPropertyName("floatingStatsMonitorDeviceName")] + public string? FloatingStatsMonitorDeviceName { get; set; } + [JsonPropertyName("floatingStatsTopmost")] public bool FloatingStatsTopmost { get; set; } = true; diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs index ed0ccda..fb9fcae 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml.cs @@ -3,6 +3,7 @@ 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; @@ -266,26 +267,35 @@ 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 preferredArea = workingAreas.Count > 0 + var primaryArea = workingAreas.Count > 0 ? workingAreas[0] - : SystemParameters.WorkArea; + : 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.Right - Width - EdgeMargin, - preferredArea.Top + EdgeMargin, + preferredArea.Bounds.Right - Width - EdgeMargin, + preferredArea.Bounds.Top + EdgeMargin, Width, Height); - var targetArea = FindBestWorkingArea(requestedBounds, workingAreas) ?? preferredArea; - var clamped = ClampToArea(requestedBounds, targetArea); + var targetArea = savedArea ?? FindBestWorkingArea(requestedBounds, workingAreas) ?? preferredArea; + var clamped = ClampToArea(requestedBounds, targetArea.Bounds); _isRestoringPosition = true; try @@ -316,10 +326,11 @@ private void ClampCurrentPositionToWorkingArea() var workingAreas = GetWorkingAreasInDips(); var preferredArea = workingAreas.Count > 0 ? workingAreas[0] - : SystemParameters.WorkArea; + : new WorkingAreaInfo(string.Empty, SystemParameters.WorkArea); var bounds = new Rect(Left, Top, Width, Height); - var targetArea = FindBestWorkingArea(bounds, workingAreas) ?? preferredArea; - var clamped = ClampToArea(bounds, targetArea); + var currentArea = FindWorkingAreaByDeviceName(GetCurrentMonitorDeviceName(), workingAreas); + var targetArea = currentArea ?? FindBestWorkingArea(bounds, workingAreas) ?? preferredArea; + var clamped = ClampToArea(bounds, targetArea.Bounds); _isRestoringPosition = true; try @@ -333,17 +344,17 @@ private void ClampCurrentPositionToWorkingArea() } } - private List GetWorkingAreasInDips() + private List GetWorkingAreasInDips() { - var areas = new List(); + var areas = new List(); var source = PresentationSource.FromVisual(this); - var fromDevice = source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity; + var fallbackTransform = source?.CompositionTarget?.TransformFromDevice ?? Matrix.Identity; foreach (var screen in Forms.Screen.AllScreens) { - var topLeft = fromDevice.Transform(new Point(screen.WorkingArea.Left, screen.WorkingArea.Top)); - var bottomRight = fromDevice.Transform(new Point(screen.WorkingArea.Right, screen.WorkingArea.Bottom)); - var area = new Rect(topLeft, bottomRight); + var area = new WorkingAreaInfo( + screen.DeviceName, + MonitorGeometryHelper.GetWorkingAreaInDips(screen, fallbackTransform)); if (screen.Primary) { areas.Insert(0, area); @@ -357,13 +368,43 @@ private List GetWorkingAreasInDips() return areas; } - private static Rect? FindBestWorkingArea(Rect bounds, IReadOnlyList workingAreas) + private string? GetCurrentMonitorDeviceName() { - Rect? bestArea = null; + 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); + var intersection = Rect.Intersect(bounds, area.Bounds); var intersectionSize = intersection.IsEmpty ? 0 : intersection.Width * intersection.Height; if (intersectionSize <= bestIntersection) { @@ -383,4 +424,17 @@ private static Rect ClampToArea(Rect bounds, Rect workingArea) 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 a66170a..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"> - + + @@ -236,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 af4d09c..c2f4245 100644 --- a/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs +++ b/KeyStats.Windows/KeyStats/Views/SettingsWindow.xaml.cs @@ -3,29 +3,36 @@ 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) @@ -39,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; @@ -55,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; diff --git a/KeyStats.Windows/design-qa.md b/KeyStats.Windows/design-qa.md index 49f5700..7079e41 100644 --- a/KeyStats.Windows/design-qa.md +++ b/KeyStats.Windows/design-qa.md @@ -1,48 +1,27 @@ # Floating Stats Design QA -- Source feedback capture: `C:\Users\t\AppData\Local\Temp\codex-clipboard-67a8ee63-df85-4c65-ab56-4e22ddc7dab7.png` -- Single-row implementation: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-single-row-tight.png` -- Double-row implementation: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-double-row.png` -- Combined comparison: `C:\Users\t\.codex\visualizations\2026\08\23\01a02c9c-fa5e-7dc3-af63-d32cab43e2a6\floating-stats-layout-comparison.png` -- Viewports: single row 104 × 36 DIPs; double row 72 × 52 DIPs; both rendered at 96 DPI / 1× density -- Pixel dimensions: feedback capture 365 × 103; single-row implementation 104 × 36; double-row implementation 72 × 52; comparison canvas 790 × 150 -- State: Windows light theme, values 225 and 252 -- Normalization: the implementation captures are rendered at 1× and enlarged to 2× in the combined comparison to approximate the high-DPI scale of the supplied feedback screenshot. +## Production geometry -## Full-view comparison evidence +- 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. -The revised single-row surface materially reduces the wide outer and inter-value whitespace visible in the supplied screenshot while preserving two clearly separated values. The new double-row option uses a narrower vertical card with a horizontal divider and equal row heights. Both layouts retain the same typography, border, radius, material, and interaction affordances. +## Static inspection -## Required fidelity surfaces +- 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. -- Fonts and typography: both layouts retain the taskbar-aligned Segoe UI 11 px semibold values with display-mode formatting and ClearType rendering. -- Spacing and layout rhythm: single row is reduced from 136 × 36 to 104 × 36, with 4 px horizontal padding and a 7 px divider gutter. Double row is 72 × 52 with 4 px horizontal padding, 3 px vertical padding, and a centered 42 px divider. -- Colors and visual tokens: both layouts continue using the KeyStats dynamic primary text, divider, translucent backdrop tint, and popup border resources. -- Image quality and asset fidelity: neither layout contains raster imagery, icons, or decorative assets. -- Copy and content: only the two selected statistic values are visible; labels and exact expanded values remain available through settings and tooltips. +## Manual verification matrix -## Focused-region comparison evidence +The following checks remain required on Windows before release: -No separate focused crop was needed because the values, separators, border, padding, and alignment are all legible in the combined comparison at the supplied high-DPI presentation scale. - -## Findings - -- No actionable P0, P1, or P2 differences. -- Accepted intentional difference: the double-row layout is narrower and taller than the supplied single-row capture because it prioritizes vertical stacking. -- P3: very long formatted distance values may truncate in the compact card; the full value remains available in the tooltip. - -## Comparison history - -- Earlier state: 136 × 36 single-row surface with excess horizontal whitespace at the user's display scale. -- Revision: reduced single-row width to 104 DIPs and added a 72 × 52 double-row layout selectable from Settings. -- Post-fix evidence: both exact production-XAML renders show centered values with no overlap or clipping for representative counts. - -## Implementation checklist - -- Single-row and double-row production XAML rendered and inspected. -- Layout selection persists through the additive settings model and applies immediately. -- Window size changes re-clamp the saved position to the visible work area. -- Debug build completed with 0 warnings and 0 errors. -- English, Simplified Chinese, and Traditional Chinese resources remain in parity. - -final result: passed +- 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. From 9a29bdee3d466d43739dd8fe78cc1fd509bbd832 Mon Sep 17 00:00:00 2001 From: tian Date: Sun, 23 Aug 2026 19:31:37 +0800 Subject: [PATCH 20/20] style: replace floating stats shadow with border --- .../KeyStats/Views/FloatingStatsWindow.xaml | 5 +++-- .../KeyStats/Views/FloatingStatsWindow.xaml.cs | 15 +++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml index 699487d..844a825 100644 --- a/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml +++ b/KeyStats.Windows/KeyStats/Views/FloatingStatsWindow.xaml @@ -6,7 +6,7 @@ Width="35" Height="41" WindowStyle="None" - AllowsTransparency="False" + AllowsTransparency="True" Background="Transparent" ShowInTaskbar="False" ShowActivated="False" @@ -17,7 +17,8 @@ TextOptions.TextRenderingMode="ClearType">