diff --git a/RadialActions.Tests/Pie/PieSelectionControllerTests.cs b/RadialActions.Tests/Pie/PieSelectionControllerTests.cs
index 5ee2f77..f23afcf 100644
--- a/RadialActions.Tests/Pie/PieSelectionControllerTests.cs
+++ b/RadialActions.Tests/Pie/PieSelectionControllerTests.cs
@@ -12,6 +12,25 @@ public class PieSelectionControllerTests
new(3, 170),
];
+ [Theory]
+ [InlineData(true, false, 1, 2, PieSelectionController.NoSelection)]
+ [InlineData(true, true, 1, 2, PieSelectionController.NoSelection)]
+ [InlineData(false, true, 1, 2, 1)]
+ [InlineData(false, true, PieSelectionController.NoSelection, 2, PieSelectionController.NoSelection)]
+ [InlineData(false, false, 1, 2, 2)]
+ [InlineData(false, false, PieSelectionController.NoSelection, PieSelectionController.NoSelection, PieSelectionController.NoSelection)]
+ public void GetReleaseTriggerIndex_FollowsInteractionMode(
+ bool isDragActive,
+ bool isKeyboardMode,
+ int selectedIndex,
+ int hoveredIndex,
+ int expectedIndex)
+ {
+ var result = PieSelectionController.GetReleaseTriggerIndex(isDragActive, isKeyboardMode, selectedIndex, hoveredIndex);
+
+ Assert.Equal(expectedIndex, result);
+ }
+
[Theory]
[InlineData(Key.Up, 0)]
[InlineData(Key.Right, 1)]
diff --git a/RadialActions/MainWindow.xaml.cs b/RadialActions/MainWindow.xaml.cs
index f55b753..a995300 100644
--- a/RadialActions/MainWindow.xaml.cs
+++ b/RadialActions/MainWindow.xaml.cs
@@ -221,6 +221,10 @@ private void OnTrayExitMenuItemClick(object sender, RoutedEventArgs e)
private void OnSliceClicked(object sender, SliceClickEventArgs e)
{
Log.Debug($"Slice clicked: {e.Slice.Name}");
+
+ // A slice has fired; releasing the still-held hotkey must not fire another one when the menu stays open.
+ _hotkeyReleasePending = false;
+
try
{
e.Slice.Execute();
@@ -323,7 +327,7 @@ private void Window_KeyUp(object sender, KeyEventArgs e)
_hotkeyReleasePending = false;
- if (PieMenu.TriggerHoveredSlice())
+ if (PieMenu.TriggerActiveSlice())
{
Log.Debug("Activation hotkey released over a slice; triggered it");
e.Handled = true;
diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs
index 44641c6..0482041 100644
--- a/RadialActions/Pie/PieControl.xaml.cs
+++ b/RadialActions/Pie/PieControl.xaml.cs
@@ -91,6 +91,15 @@ private void OnSizeChanged(object sender, SizeChangedEventArgs e)
RequestRenderRefresh();
}
+ protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
+ {
+ base.OnDpiChanged(oldDpi, newDpi);
+
+ // Snapped geometry and the surface shadow's BitmapCache scale are baked at build-time DPI, and the menu's
+ // size in DIPs doesn't change when it opens on a different-DPI monitor, so nothing else triggers a rebuild.
+ RequestRenderRefresh();
+ }
+
private void OnLoaded(object sender, RoutedEventArgs e)
{
SystemParameters.StaticPropertyChanged += OnSystemParametersChanged;
@@ -127,27 +136,34 @@ private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArg
public void ResetInputState()
{
+ // The visuals survive across opens, so press and drag tracking from the previous open must be discarded
+ // here or a held button in the next open can resume a drag that was never started there.
+ _drag = null;
+ _dragCandidate = null;
+
EnterMouseInteractionMode(refreshVisualState: true, animate: false);
}
///
- /// Triggers the slice currently under the mouse, if any.
+ /// Triggers the active slice: the keyboard-selected slice in keyboard mode, otherwise the slice under the mouse.
///
- /// True if a hovered slice was triggered.
- public bool TriggerHoveredSlice()
+ /// True if a slice was triggered.
+ public bool TriggerActiveSlice()
{
- if (_drag != null)
+ var hoveredIndex = _sliceVisuals.FirstOrDefault(slice => slice.Path.IsMouseOver)?.Index ?? PieSelectionController.NoSelection;
+ var targetIndex = PieSelectionController.GetReleaseTriggerIndex(
+ _drag != null,
+ _interactionMode == InteractionMode.Keyboard,
+ _selectionController.SelectedIndex,
+ hoveredIndex);
+
+ var targetSlice = _sliceVisuals.FirstOrDefault(slice => slice.Index == targetIndex);
+ if (targetSlice == null)
{
return false;
}
- var hoveredSlice = _sliceVisuals.FirstOrDefault(slice => slice.Path.IsMouseOver);
- if (hoveredSlice == null)
- {
- return false;
- }
-
- SliceClicked?.Invoke(this, new SliceClickEventArgs(hoveredSlice.Action));
+ SliceClicked?.Invoke(this, new SliceClickEventArgs(targetSlice.Action));
return true;
}
@@ -345,6 +361,9 @@ private void CreatePieMenu()
ActualWidth,
ActualHeight);
_selectionController.Reset();
+
+ // Nothing was rendered; keep the refresh pending so the next opportunity (like the next open) retries.
+ _renderRefreshPending = true;
return;
}
@@ -368,6 +387,9 @@ private void CreatePieMenu()
Slices?.Count ?? 0,
ActualWidth,
ActualHeight);
+
+ // Nothing was rendered; keep the refresh pending so the next opportunity (like the next open) retries.
+ _renderRefreshPending = true;
return;
}
@@ -863,9 +885,11 @@ private void CommitReorder(PieSliceVisual sliceVisual, int targetSlot)
// Move the dragged action to the position of the action that was built at the target
// slot; disabled actions keep their relative placement in the collection.
- var targetAction = _sliceVisuals.First(visual => visual.Index == targetSlot).Action;
+ // The commit runs from an animation callback, so a rebuild (settings edit, theme change) may have
+ // replaced the visuals in the meantime and the target slot may no longer exist.
+ var targetAction = _sliceVisuals.FirstOrDefault(visual => visual.Index == targetSlot)?.Action;
var fromIndex = Slices.IndexOf(sliceVisual.Action);
- var toIndex = Slices.IndexOf(targetAction);
+ var toIndex = targetAction == null ? -1 : Slices.IndexOf(targetAction);
if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex)
{
Log.Warning(
@@ -1034,6 +1058,13 @@ private void ApplyBrushColor(SolidColorBrush brush, Color color, bool animate)
private void OnSystemParametersChanged(object sender, PropertyChangedEventArgs e)
{
+ // SystemEvents can deliver on a worker thread, and the refresh path reads dependency properties.
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(() => OnSystemParametersChanged(sender, e));
+ return;
+ }
+
var propertyName = e.PropertyName;
if (string.IsNullOrEmpty(propertyName)
|| propertyName.Contains("Color", StringComparison.OrdinalIgnoreCase)
@@ -1047,6 +1078,13 @@ private void OnSystemParametersChanged(object sender, PropertyChangedEventArgs e
private void OnUserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e)
{
+ // SystemEvents can deliver on a worker thread, and the refresh path reads dependency properties.
+ if (!Dispatcher.CheckAccess())
+ {
+ Dispatcher.BeginInvoke(() => OnUserPreferenceChanged(sender, e));
+ return;
+ }
+
RequestRenderRefresh();
}
diff --git a/RadialActions/Pie/PieSelectionController.cs b/RadialActions/Pie/PieSelectionController.cs
index 48ef2a8..c4946e7 100644
--- a/RadialActions/Pie/PieSelectionController.cs
+++ b/RadialActions/Pie/PieSelectionController.cs
@@ -15,6 +15,21 @@ public void Reset()
SelectedIndex = NoSelection;
}
+ ///
+ /// Decides which slice a hotkey-release flick should trigger, honoring the active interaction mode
+ /// so the triggered slice always matches the one shown highlighted.
+ ///
+ /// The slice index to trigger, or to trigger nothing.
+ public static int GetReleaseTriggerIndex(bool isDragActive, bool isKeyboardMode, int selectedIndex, int hoveredIndex)
+ {
+ if (isDragActive)
+ {
+ return NoSelection;
+ }
+
+ return isKeyboardMode ? selectedIndex : hoveredIndex;
+ }
+
public void EnsureSelectionIsValid(IReadOnlyList- items)
{
if (SelectedIndex == NoSelection)