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)