From 4e62360eb940a8c214b35df062cf80aaef9c6dc1 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 20 Jul 2026 09:17:32 -0500 Subject: [PATCH] Flick gestures: release the hotkey to trigger the hovered slice Hold the activation hotkey, glide over a slice, and release to run it in one motion. Registration now uses MOD_NOREPEAT so holding the keys no longer re-toggles the menu closed, and the menu window watches for the key-up of any hotkey component to fire the hovered slice through the existing SliceClicked pipeline. A quick tap falls back to click mode since the cursor starts over the center hub. Controlled by a new "Release hotkey to trigger" setting, on by default. Closes #55 --- .../Hotkeys/HotkeyUtilTests.cs | 22 ++++++++ .../Settings/SettingsSerializationTests.cs | 11 ++++ RadialActions/Hotkeys/HotkeyManager.cs | 7 ++- RadialActions/Hotkeys/HotkeyUtil.cs | 18 +++++++ RadialActions/MainWindow.xaml | 1 + RadialActions/MainWindow.xaml.cs | 51 ++++++++++++++++--- RadialActions/Pie/PieControl.xaml.cs | 21 ++++++++ RadialActions/Properties/Settings.cs | 6 +++ .../Settings/GeneralSettingsView.xaml | 25 +++++++++ 9 files changed, 154 insertions(+), 8 deletions(-) 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 @@ + + + + + + + + + + + + + + + +