diff --git a/RadialActions.Tests/Hotkeys/HotkeyUtilTests.cs b/RadialActions.Tests/Hotkeys/HotkeyUtilTests.cs
index bbf87d7..4070482 100644
--- a/RadialActions.Tests/Hotkeys/HotkeyUtilTests.cs
+++ b/RadialActions.Tests/Hotkeys/HotkeyUtilTests.cs
@@ -22,6 +22,28 @@ public void TryParse_InvalidToken_ReturnsFalse()
Assert.False(ok);
}
+ [Theory]
+ [InlineData(Key.Space, true)]
+ [InlineData(Key.LeftCtrl, true)]
+ [InlineData(Key.RightCtrl, true)]
+ [InlineData(Key.LeftAlt, true)]
+ [InlineData(Key.RightAlt, true)]
+ [InlineData(Key.LeftShift, false)]
+ [InlineData(Key.LWin, false)]
+ [InlineData(Key.A, false)]
+ public void IsHotkeyComponent_CtrlAltSpace_MatchesMainKeyAndModifiers(Key key, bool expected)
+ {
+ var isComponent = HotkeyUtil.IsHotkeyComponent(key, ModifierKeys.Control | ModifierKeys.Alt, Key.Space);
+
+ Assert.Equal(expected, isComponent);
+ }
+
+ [Fact]
+ public void IsHotkeyComponent_NoneKey_NeverMatches()
+ {
+ Assert.False(HotkeyUtil.IsHotkeyComponent(Key.None, ModifierKeys.None, Key.None));
+ }
+
[Theory]
[InlineData(Key.Space, ModifierKeys.Control | ModifierKeys.Alt)]
[InlineData(Key.F12, ModifierKeys.Shift)]
diff --git a/RadialActions.Tests/Settings/SettingsSerializationTests.cs b/RadialActions.Tests/Settings/SettingsSerializationTests.cs
index e7232e8..ed3040b 100644
--- a/RadialActions.Tests/Settings/SettingsSerializationTests.cs
+++ b/RadialActions.Tests/Settings/SettingsSerializationTests.cs
@@ -132,6 +132,17 @@ public void DeserializeFromJson_ScriptActionMissingRunHidden_DefaultsToFalse()
Assert.False(settings.Actions[0].RunHidden);
}
+ [Fact]
+ public void TriggerSliceOnHotkeyRelease_DefaultsToTrueAndRoundTrips()
+ {
+ var settings = Settings.DeserializeFromJson("{}");
+ Assert.True(settings.TriggerSliceOnHotkeyRelease);
+
+ settings.TriggerSliceOnHotkeyRelease = false;
+ var loaded = Settings.DeserializeFromJson(settings.SerializeToJson());
+ Assert.False(loaded.TriggerSliceOnHotkeyRelease);
+ }
+
[Fact]
public void DeserializeFromJson_MissingIsEnabled_DefaultsToTrue()
{
diff --git a/RadialActions/Hotkeys/HotkeyManager.cs b/RadialActions/Hotkeys/HotkeyManager.cs
index db8e3a2..833d56c 100644
--- a/RadialActions/Hotkeys/HotkeyManager.cs
+++ b/RadialActions/Hotkeys/HotkeyManager.cs
@@ -16,6 +16,11 @@ public class HotkeyManager : IDisposable
private const uint ModControl = 0x0002;
private const uint ModShift = 0x0004;
private const uint ModWin = 0x0008;
+
+ ///
+ /// Prevents keyboard autorepeat from firing extra WM_HOTKEY messages while the hotkey is held.
+ ///
+ private const uint ModNoRepeat = 0x4000;
private int _currentId;
private readonly Dictionary _hotkeys = new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
@@ -74,7 +79,7 @@ public bool RegisterHotkey(string hotkey)
var id = ++_currentId;
- if (RegisterHotKey(_windowHandle, id, ToModifierFlags(modifiers), keyCode))
+ if (RegisterHotKey(_windowHandle, id, ToModifierFlags(modifiers) | ModNoRepeat, keyCode))
{
_hotkeys[hotkey] = id;
Log.Information($"Registered hotkey: {hotkey} (ID: {id})");
diff --git a/RadialActions/Hotkeys/HotkeyUtil.cs b/RadialActions/Hotkeys/HotkeyUtil.cs
index 465411c..21dfadb 100644
--- a/RadialActions/Hotkeys/HotkeyUtil.cs
+++ b/RadialActions/Hotkeys/HotkeyUtil.cs
@@ -90,6 +90,24 @@ public static bool TryParse(string hotkey, out ModifierKeys modifiers, out Key k
return key != Key.None;
}
+ ///
+ /// Returns whether a key is part of a hotkey combination, either as its main key or as one of its modifiers.
+ ///
+ public static bool IsHotkeyComponent(Key key, ModifierKeys modifiers, Key hotkeyKey)
+ {
+ if (key == hotkeyKey && key != Key.None)
+ return true;
+
+ return key switch
+ {
+ Key.LeftCtrl or Key.RightCtrl => modifiers.HasFlag(ModifierKeys.Control),
+ Key.LeftAlt or Key.RightAlt => modifiers.HasFlag(ModifierKeys.Alt),
+ Key.LeftShift or Key.RightShift => modifiers.HasFlag(ModifierKeys.Shift),
+ Key.LWin or Key.RWin => modifiers.HasFlag(ModifierKeys.Windows),
+ _ => false
+ };
+ }
+
public static string BuildHotkeyString(Key key, ModifierKeys modifiers)
{
if (key == Key.None)
diff --git a/RadialActions/MainWindow.xaml b/RadialActions/MainWindow.xaml
index fe59194..616b240 100644
--- a/RadialActions/MainWindow.xaml
+++ b/RadialActions/MainWindow.xaml
@@ -10,6 +10,7 @@
d:DataContext="{d:DesignInstance Type=local:MainWindow}"
AllowsTransparency="True" Background="Transparent"
Deactivated="Window_Deactivated" PreviewKeyDown="Window_KeyDown"
+ PreviewKeyUp="Window_KeyUp"
Loaded="Window_Loaded" Opacity="0"
ResizeMode="NoResize" ShowActivated="False" ShowInTaskbar="False"
SizeToContent="WidthAndHeight"
diff --git a/RadialActions/MainWindow.xaml.cs b/RadialActions/MainWindow.xaml.cs
index 87d39de..f55b753 100644
--- a/RadialActions/MainWindow.xaml.cs
+++ b/RadialActions/MainWindow.xaml.cs
@@ -21,6 +21,12 @@ public partial class MainWindow : Window
private readonly HotkeyService _hotkeyService = new();
private readonly MenuService _menuService;
+ ///
+ /// True while the menu was opened by the activation hotkey and the keys have not been released
+ /// yet, so releasing them over a slice triggers it (flick gesture).
+ ///
+ private bool _hotkeyReleasePending;
+
public MainWindow()
{
InitializeComponent();
@@ -96,22 +102,25 @@ public void Exit()
public void ShowMenu(bool atCursor)
{
+ _hotkeyReleasePending = false;
_menuService.ShowMenu(atCursor);
}
public void HideMenu(bool animate = true)
{
+ _hotkeyReleasePending = false;
_menuService.HideMenu(animate);
}
private void ShowMenuUsingConfiguredPosition()
{
+ _hotkeyReleasePending = false;
_menuService.ShowMenu(!Settings.Default.OpenMenuInScreenCenter);
}
private async void Window_Loaded(object sender, RoutedEventArgs e)
{
- _menuService.HideMenu(animate: false);
+ HideMenu(animate: false);
var handle = new WindowInteropHelper(this).Handle;
_hotkeyService.Initialize(handle, OnHotkeyPressed);
@@ -137,11 +146,12 @@ private void OnHotkeyPressed(object sender, EventArgs e)
if (IsActive)
{
- _menuService.HideMenu();
+ HideMenu();
}
else
{
ShowMenuUsingConfiguredPosition();
+ _hotkeyReleasePending = Settings.Default.TriggerSliceOnHotkeyRelease;
}
}
@@ -223,7 +233,7 @@ private void OnSliceClicked(object sender, SliceClickEventArgs e)
if (!Settings.Default.KeepMenuOpenAfterSliceClick)
{
- _menuService.HideMenu();
+ HideMenu();
}
}
@@ -239,7 +249,7 @@ private void OnSlicesReordered(object sender, EventArgs e)
private void OnCenterClicked(object sender, EventArgs e)
{
Log.Debug("Center close target clicked");
- _menuService.HideMenu();
+ HideMenu();
}
private void OnCenterContextMenuRequested(object sender, EventArgs e)
@@ -263,13 +273,13 @@ private void OnSliceEditRequested(object sender, SliceClickEventArgs e)
OpenSettingsWindow(1);
var settingsWindow = Application.Current.Windows.OfType().FirstOrDefault();
settingsWindow?.SelectAction(e.Slice);
- _menuService.HideMenu();
+ HideMenu();
}
private void Window_Deactivated(object sender, EventArgs e)
{
Log.Debug("Lost focus");
- _menuService.HideMenu();
+ HideMenu();
}
private void Window_KeyDown(object sender, KeyEventArgs e)
@@ -277,7 +287,7 @@ private void Window_KeyDown(object sender, KeyEventArgs e)
if (e.Key == Key.Escape)
{
Log.Debug("Escape pressed");
- _menuService.HideMenu();
+ HideMenu();
e.Handled = true;
return;
}
@@ -297,6 +307,33 @@ private void Window_KeyDown(object sender, KeyEventArgs e)
}
}
+ private void Window_KeyUp(object sender, KeyEventArgs e)
+ {
+ if (!_hotkeyReleasePending)
+ {
+ return;
+ }
+
+ var key = e.Key == Key.System ? e.SystemKey : e.Key;
+ if (!HotkeyUtil.TryParse(Settings.Default.ActivationHotkey, out var modifiers, out var hotkeyKey)
+ || !HotkeyUtil.IsHotkeyComponent(key, modifiers, hotkeyKey))
+ {
+ return;
+ }
+
+ _hotkeyReleasePending = false;
+
+ if (PieMenu.TriggerHoveredSlice())
+ {
+ Log.Debug("Activation hotkey released over a slice; triggered it");
+ e.Handled = true;
+ }
+ else
+ {
+ Log.Debug("Activation hotkey released with no slice hovered; menu stays open");
+ }
+ }
+
private void FadeOutStoryboard_Completed(object sender, EventArgs e)
{
_menuService.OnFadeOutCompleted();
diff --git a/RadialActions/Pie/PieControl.xaml.cs b/RadialActions/Pie/PieControl.xaml.cs
index 5f24569..c893a8f 100644
--- a/RadialActions/Pie/PieControl.xaml.cs
+++ b/RadialActions/Pie/PieControl.xaml.cs
@@ -130,6 +130,27 @@ public void ResetInputState()
EnterMouseInteractionMode(refreshVisualState: true, animate: false);
}
+ ///
+ /// Triggers the slice currently under the mouse, if any.
+ ///
+ /// True if a hovered slice was triggered.
+ public bool TriggerHoveredSlice()
+ {
+ if (_drag != null)
+ {
+ return false;
+ }
+
+ var hoveredSlice = _sliceVisuals.FirstOrDefault(slice => slice.Path.IsMouseOver);
+ if (hoveredSlice == null)
+ {
+ return false;
+ }
+
+ SliceClicked?.Invoke(this, new SliceClickEventArgs(hoveredSlice.Action));
+ return true;
+ }
+
public bool HandleMenuKey(Key key, ModifierKeys modifiers)
{
switch (key)
diff --git a/RadialActions/Properties/Settings.cs b/RadialActions/Properties/Settings.cs
index a873f53..4444237 100644
--- a/RadialActions/Properties/Settings.cs
+++ b/RadialActions/Properties/Settings.cs
@@ -40,6 +40,12 @@ public sealed partial class Settings
[ObservableProperty]
private bool _keepMenuOpenAfterSliceClick;
+ ///
+ /// Triggers the hovered slice when the held activation hotkey is released.
+ ///
+ [ObservableProperty]
+ private bool _triggerSliceOnHotkeyRelease = true;
+
///
/// Opens the menu at the center of the current screen instead of the cursor.
///
diff --git a/RadialActions/Settings/GeneralSettingsView.xaml b/RadialActions/Settings/GeneralSettingsView.xaml
index 6a2180d..010ed5c 100644
--- a/RadialActions/Settings/GeneralSettingsView.xaml
+++ b/RadialActions/Settings/GeneralSettingsView.xaml
@@ -45,6 +45,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+