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)