From c098437e8deebab8f0b8a1d066d7e5e8f0e356f6 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sat, 25 Jul 2026 16:16:20 -0500 Subject: [PATCH] Sync animation frame rate to the refresh rate of the menu's display --- RadialActions/Interop/WpfUtil.cs | 78 +++++++++++++++++++++--- RadialActions/Pie/PieAnimationService.cs | 12 +++- RadialActions/Pie/PieControl.xaml.cs | 9 +++ RadialActions/Services/MenuService.cs | 17 ++++++ 4 files changed, 108 insertions(+), 8 deletions(-) diff --git a/RadialActions/Interop/WpfUtil.cs b/RadialActions/Interop/WpfUtil.cs index ba743f0..203c87b 100644 --- a/RadialActions/Interop/WpfUtil.cs +++ b/RadialActions/Interop/WpfUtil.cs @@ -5,7 +5,7 @@ namespace RadialActions; /// -/// Utility methods for WPF window positioning. +/// Utility methods for WPF window positioning and display information. /// public static class WpfUtil { @@ -19,8 +19,11 @@ public static class WpfUtil [DllImport("user32.dll")] private static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); - [DllImport("user32.dll")] - private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFO lpmi); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern bool EnumDisplaySettings(string lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode); [DllImport("shcore.dll")] private static extern int GetDpiForMonitor(IntPtr hmonitor, MonitorDpiType dpiType, out uint dpiX, out uint dpiY); @@ -41,13 +44,52 @@ private struct RECT public int Bottom; } - [StructLayout(LayoutKind.Sequential)] - private struct MONITORINFO + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct MONITORINFOEX { public int cbSize; public RECT rcMonitor; public RECT rcWork; public uint dwFlags; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string szDevice; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct DEVMODE + { + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmDeviceName; + public ushort dmSpecVersion; + public ushort dmDriverVersion; + public ushort dmSize; + public ushort dmDriverExtra; + public uint dmFields; + public int dmPositionX; + public int dmPositionY; + public uint dmDisplayOrientation; + public uint dmDisplayFixedOutput; + public short dmColor; + public short dmDuplex; + public short dmYResolution; + public short dmTTOption; + public short dmCollate; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] + public string dmFormName; + public ushort dmLogPixels; + public uint dmBitsPerPel; + public uint dmPelsWidth; + public uint dmPelsHeight; + public uint dmDisplayFlags; + public uint dmDisplayFrequency; + public uint dmICMMethod; + public uint dmICMIntent; + public uint dmMediaType; + public uint dmDitherType; + public uint dmReserved1; + public uint dmReserved2; + public uint dmPanningWidth; + public uint dmPanningHeight; } private enum MonitorDpiType @@ -58,6 +100,7 @@ private enum MonitorDpiType private const uint SWP_NOZORDER = 0x0004; private const uint SWP_NOSIZE = 0x0001; private const uint MONITOR_DEFAULTTONEAREST = 2; + private const int ENUM_CURRENT_SETTINGS = -1; private const int DefaultDpi = 96; /// @@ -145,13 +188,34 @@ internal static int ScaleDipToPixels(double dipValue, double dpiScale) return (int)Math.Round(dipValue * dpiScale); } - private static bool TryGetMonitorInfo(POINT point, out IntPtr monitor, out MONITORINFO monitorInfo) + private static bool TryGetMonitorInfo(POINT point, out IntPtr monitor, out MONITORINFOEX monitorInfo) { monitor = MonitorFromPoint(point, MONITOR_DEFAULTTONEAREST); - monitorInfo = new MONITORINFO { cbSize = Marshal.SizeOf() }; + monitorInfo = new MONITORINFOEX { cbSize = Marshal.SizeOf() }; return monitor != IntPtr.Zero && GetMonitorInfo(monitor, ref monitorInfo); } + /// + /// Gets the refresh rate in Hz of the display the cursor is currently on, or 0 if it can't be determined. + /// + public static int GetCursorMonitorRefreshRate() + { + if (!GetCursorPos(out var cursorPosition) || !TryGetMonitorInfo(cursorPosition, out _, out var monitorInfo)) + { + return 0; + } + + var devMode = new DEVMODE { dmSize = (ushort)Marshal.SizeOf() }; + + // A frequency of 0 or 1 means the hardware default, not a real rate. + if (EnumDisplaySettings(monitorInfo.szDevice, ENUM_CURRENT_SETTINGS, ref devMode) && devMode.dmDisplayFrequency > 1) + { + return (int)devMode.dmDisplayFrequency; + } + + return 0; + } + private static Size GetWindowSize(Window window) { var width = window.ActualWidth; diff --git a/RadialActions/Pie/PieAnimationService.cs b/RadialActions/Pie/PieAnimationService.cs index d3b293d..64a0e8b 100644 --- a/RadialActions/Pie/PieAnimationService.cs +++ b/RadialActions/Pie/PieAnimationService.cs @@ -1,4 +1,4 @@ -using System.Windows; +using System.Windows; using System.Windows.Media; using System.Windows.Media.Animation; @@ -6,6 +6,11 @@ namespace RadialActions; internal sealed class PieAnimationService { + /// + /// Desired frame rate in Hz for animations, matching the refresh rate of the display the menu is on. Null uses the WPF default of about 60. + /// + public int? DesiredFrameRate { get; set; } + public void ApplyBrushColor( SolidColorBrush brush, Color color, @@ -43,6 +48,7 @@ public void AnimateBrushColor( EasingFunction = easingFunction, }; + Timeline.SetDesiredFrameRate(colorAnimation, DesiredFrameRate); brush.BeginAnimation(SolidColorBrush.ColorProperty, colorAnimation, HandoffBehavior.SnapshotAndReplace); } @@ -73,6 +79,7 @@ public void AnimateOpacity( opacityAnimation.Completed += (_, _) => onCompleted(); } + Timeline.SetDesiredFrameRate(opacityAnimation, DesiredFrameRate); element.BeginAnimation(UIElement.OpacityProperty, opacityAnimation, HandoffBehavior.SnapshotAndReplace); } @@ -103,6 +110,7 @@ public void AnimateRotationAngle( rotationAnimation.Completed += (_, _) => onCompleted(); } + Timeline.SetDesiredFrameRate(rotationAnimation, DesiredFrameRate); transform.BeginAnimation(RotateTransform.AngleProperty, rotationAnimation, HandoffBehavior.SnapshotAndReplace); } @@ -133,6 +141,7 @@ public void AnimateClickDown(UIElement target, Duration duration, IEasingFunctio FillBehavior = FillBehavior.HoldEnd, }; + Timeline.SetDesiredFrameRate(scaleAnimation, DesiredFrameRate); scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); } @@ -161,6 +170,7 @@ public void AnimateClickUp(UIElement target, Duration duration, IEasingFunction EasingFunction = easingFunction, }; + Timeline.SetDesiredFrameRate(scaleAnimation, DesiredFrameRate); scaleTransform.BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); scaleTransform.BeginAnimation(ScaleTransform.ScaleYProperty, scaleAnimation, HandoffBehavior.SnapshotAndReplace); } diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs index c893a8f..ab3555a 100644 --- a/RadialActions/Pie/PieControl.xaml.cs +++ b/RadialActions/Pie/PieControl.xaml.cs @@ -76,6 +76,15 @@ private sealed class DragReorderState public required int TargetSlot { get; set; } } + /// + /// Desired frame rate in Hz for pie animations, matching the refresh rate of the display the menu is on. Null uses the WPF default of about 60. + /// + public int? AnimationFrameRate + { + get => _animationService.DesiredFrameRate; + set => _animationService.DesiredFrameRate = value; + } + public PieControl() { InitializeComponent(); diff --git a/RadialActions/Services/MenuService.cs b/RadialActions/Services/MenuService.cs index fbc6aa6..d27cb4a 100644 --- a/RadialActions/Services/MenuService.cs +++ b/RadialActions/Services/MenuService.cs @@ -41,6 +41,8 @@ public void ShowMenu(bool atCursor) _window.CenterOnScreen(); } + SyncAnimationFrameRate(); + if (!_window.IsVisible) { _window.Opacity = 0; @@ -54,6 +56,21 @@ public void ShowMenu(bool atCursor) BeginFadeIn(); } + /// + /// Points the animation clock at the refresh rate of the display the menu is opening on, so animations aren't capped at the WPF default of about 60 fps. + /// Re-queried on every show because the menu can open on a different display each time and rates change with hotplug or settings. + /// + private void SyncAnimationFrameRate() + { + var refreshRate = WpfUtil.GetCursorMonitorRefreshRate(); + int? desiredFrameRate = refreshRate > 0 ? refreshRate : null; + Log.Debug($"Display refresh rate: {refreshRate} Hz"); + + Timeline.SetDesiredFrameRate(_fadeInStoryboard, desiredFrameRate); + Timeline.SetDesiredFrameRate(_fadeOutStoryboard, desiredFrameRate); + _pieMenu.AnimationFrameRate = desiredFrameRate; + } + public void HideMenu(bool animate = true) { if (!_window.IsVisible || _isFadingOut)