From 4f8a0726c8ee3c08bc44c76825246b6129e04ebb Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 9 Aug 2026 22:15:16 -0500 Subject: [PATCH 1/7] Clear the hotkey release trigger once a slice fires With KeepMenuOpenAfterSliceClick enabled, clicking a slice while holding the activation hotkey left the release latch armed, so releasing the hotkey executed the same slice a second time. --- RadialActions/MainWindow.xaml.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RadialActions/MainWindow.xaml.cs b/RadialActions/MainWindow.xaml.cs index f55b753..a30be24 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(); From 90e3e0a942c97e36c4164641e542acbf29c8b69a Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 9 Aug 2026 22:15:34 -0500 Subject: [PATCH 2/7] Reset drag tracking when the menu opens Since the pie stopped rebuilding on every open, a press-and-hold that ended while the menu was hidden left the drag candidate armed. Moving over that slice with the button held in the next open then started a reorder drag measured against the stale press position, and a stale active drag would silently disable the hotkey-release flick. --- RadialActions/Pie/PieControl.xaml.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs index 44641c6..718b8d4 100644 --- a/RadialActions/Pie/PieControl.xaml.cs +++ b/RadialActions/Pie/PieControl.xaml.cs @@ -127,6 +127,11 @@ 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); } From 37248558bb596086dd6c57284ef43ba2246dacd3 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 9 Aug 2026 22:16:42 -0500 Subject: [PATCH 3/7] Flick triggers the keyboard-selected slice in keyboard mode Releasing the activation hotkey triggered the mouse-hovered slice even while arrow keys had a different slice highlighted, so the visual selection and the executed action could disagree. The release decision now lives in a pure helper that honors the interaction mode. --- .../Pie/PieSelectionControllerTests.cs | 19 +++++++++++++++ RadialActions/MainWindow.xaml.cs | 2 +- RadialActions/Pie/PieControl.xaml.cs | 24 ++++++++++--------- RadialActions/Pie/PieSelectionController.cs | 15 ++++++++++++ 4 files changed, 48 insertions(+), 12 deletions(-) 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 a30be24..a995300 100644 --- a/RadialActions/MainWindow.xaml.cs +++ b/RadialActions/MainWindow.xaml.cs @@ -327,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 718b8d4..b666a20 100644 --- a/RadialActions/Pie/PieControl.xaml.cs +++ b/RadialActions/Pie/PieControl.xaml.cs @@ -136,23 +136,25 @@ public void ResetInputState() } /// - /// 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) - { - return false; - } - - var hoveredSlice = _sliceVisuals.FirstOrDefault(slice => slice.Path.IsMouseOver); - if (hoveredSlice == 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; } - SliceClicked?.Invoke(this, new SliceClickEventArgs(hoveredSlice.Action)); + SliceClicked?.Invoke(this, new SliceClickEventArgs(targetSlice.Action)); return true; } 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) From 46b6efe3b70793550f695b7b1f58b49b8ad5969d Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 9 Aug 2026 22:16:57 -0500 Subject: [PATCH 4/7] Survive a mid-animation rebuild when committing a slice reorder The reorder commit runs from the settle animation callback. A rebuild landing during those 150 ms (settings edit, theme change, slice toggle) replaces the visuals, and looking up the target slot with First threw InvalidOperationException into the dispatcher. Fall back to the existing warning path instead. --- RadialActions/Pie/PieControl.xaml.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs index b666a20..dce9c54 100644 --- a/RadialActions/Pie/PieControl.xaml.cs +++ b/RadialActions/Pie/PieControl.xaml.cs @@ -870,9 +870,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( From 141140089ec85f70cb4a2358b853e27088901829 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 9 Aug 2026 22:17:42 -0500 Subject: [PATCH 5/7] Keep the render refresh pending when a pie build produces nothing The build clears the pending flag up front, so its failure early-returns (zero size, no enabled slices, layout failure) left a blank pie that a later open would not repair now that opens no longer rebuild unconditionally. Re-arm the flag on those paths so the next visibility change retries. --- RadialActions/Pie/PieControl.xaml.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs index dce9c54..7abe3e6 100644 --- a/RadialActions/Pie/PieControl.xaml.cs +++ b/RadialActions/Pie/PieControl.xaml.cs @@ -352,6 +352,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; } @@ -375,6 +378,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; } From 3f9c34e2fdbfcaa79703d44583b38a48c7bdb117 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 9 Aug 2026 22:18:07 -0500 Subject: [PATCH 6/7] Marshal system notification handlers to the UI thread SystemEvents.UserPreferenceChanged is raised on whichever thread owns the broadcast window; the refresh path reads dependency properties and would throw off the UI thread. MainWindow already guards its settings handler the same way. --- RadialActions/Pie/PieControl.xaml.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs index 7abe3e6..ff0b810 100644 --- a/RadialActions/Pie/PieControl.xaml.cs +++ b/RadialActions/Pie/PieControl.xaml.cs @@ -1049,6 +1049,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) @@ -1062,6 +1069,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(); } From 513140d25faff2356ece41abd4a97adad5bbb7b4 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 9 Aug 2026 22:18:25 -0500 Subject: [PATCH 7/7] Rebuild the pie when its DPI changes Pixel snapping and the shadow BitmapCache scale are captured at build time, and a move to a different-DPI monitor changes neither the DIP size nor any other rebuild trigger, so the ring rendered soft until an unrelated event forced a rebuild. --- RadialActions/Pie/PieControl.xaml.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs index ff0b810..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;