From 879a775838cf5353e5b860ba3c00c050b6e25fd1 Mon Sep 17 00:00:00 2001 From: Stuart Meeks Date: Sat, 30 May 2026 09:14:13 +0000 Subject: [PATCH 1/2] Add global hotkey rebinding UI The global hotkey is now editable from Settings instead of display-only. A focusable HotkeyCaptureBox records a chord (modifier(s) + key), and SettingsViewModel.RebindHotkey re-registers via IHotkeyService and persists on success; on failure (chord already taken) it restores the previous binding and surfaces a notice. A Reset button restores Ctrl+Alt+S. Chord formatting + validation moved to HotkeyBinding (IsValid, ToDisplayString) in Core and unit-tested; the capture control is the only WinUI-side piece. SettingsViewModel now takes IHotkeyService. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 + .../Controls/HotkeyCaptureBox.xaml | 26 +++ .../Controls/HotkeyCaptureBox.xaml.cs | 183 ++++++++++++++++++ .../EmptyStringToVisibilityConverter.cs | 22 +++ src/Snipdeck.App/Views/ShellPage.xaml | 20 +- src/Snipdeck.Core/Models/HotkeyBinding.cs | 39 ++++ .../ViewModels/SettingsViewModel.cs | 73 ++++--- .../Models/HotkeyBindingTests.cs | 37 ++++ .../Support/FakeHotkeyService.cs | 37 ++++ .../ViewModels/SettingsViewModelTests.cs | 90 +++++++++ .../ViewModels/ShellViewModelTests.cs | 1 + 11 files changed, 502 insertions(+), 32 deletions(-) create mode 100644 src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml create mode 100644 src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml.cs create mode 100644 src/Snipdeck.App/Converters/EmptyStringToVisibilityConverter.cs create mode 100644 tests/Snipdeck.Core.Tests/Models/HotkeyBindingTests.cs create mode 100644 tests/Snipdeck.Core.Tests/Support/FakeHotkeyService.cs create mode 100644 tests/Snipdeck.Core.Tests/ViewModels/SettingsViewModelTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 74a6f19..1e04fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Rebindable global hotkey.** The global hotkey is now editable from + Settings: click the capture box and press a shortcut (at least one of + Ctrl/Alt/Shift plus a key). The new binding registers and persists + immediately; if the chord is already taken by another app, the previous + binding is restored and a brief notice is shown. A "Reset" button restores + the default (Ctrl+Alt+S). Previously the hotkey was display-only. - **Markdown rendering for snip descriptions.** A snip's description is now rendered as Markdown (headings, bold/italic, inline and block code, links, ordered/unordered lists) in the copy flyout, instead of being hidden. The diff --git a/src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml b/src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml new file mode 100644 index 0000000..3c73768 --- /dev/null +++ b/src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml.cs b/src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml.cs new file mode 100644 index 0000000..2b9c795 --- /dev/null +++ b/src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml.cs @@ -0,0 +1,183 @@ +using System.Windows.Input; + +using Microsoft.UI.Input; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; + +using Snipdeck.Core.Models; + +using Windows.System; +using Windows.UI.Core; + +namespace Snipdeck.App.Controls +{ + /// + /// A focusable box that records a global-hotkey chord. Click (or focus) it, + /// then press a modifier combination plus a key; it builds a + /// and invokes . Esc cancels. + /// Modifier-only and unsupported keys are ignored so the box keeps listening + /// until a usable chord (≥1 modifier + key) is pressed. + /// + public sealed partial class HotkeyCaptureBox : UserControl + { + private const string _recordingPrompt = "Press a shortcut… (Esc to cancel)"; + + private bool _recording; + + public static readonly DependencyProperty CommandProperty = + DependencyProperty.Register(nameof(Command), typeof(ICommand), typeof(HotkeyCaptureBox), + new PropertyMetadata(null)); + + public static readonly DependencyProperty CurrentTextProperty = + DependencyProperty.Register(nameof(CurrentText), typeof(string), typeof(HotkeyCaptureBox), + new PropertyMetadata(string.Empty, OnCurrentTextChanged)); + + public HotkeyCaptureBox() + { + InitializeComponent(); + UpdateLabel(); + } + + /// Executed with the captured when a chord is recorded. + public ICommand? Command + { + get => (ICommand?)GetValue(CommandProperty); + set => SetValue(CommandProperty, value); + } + + /// The current binding's display text, shown when not recording. + public string CurrentText + { + get => (string)GetValue(CurrentTextProperty); + set => SetValue(CurrentTextProperty, value); + } + + private static void OnCurrentTextChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + ((HotkeyCaptureBox)d).UpdateLabel(); + } + + private void OnTapped(object sender, TappedRoutedEventArgs e) + { + StartRecording(); + _ = Focus(FocusState.Programmatic); + } + + private void OnLostFocus(object sender, RoutedEventArgs e) + { + StopRecording(); + } + + private void OnKeyDown(object sender, KeyRoutedEventArgs e) + { + if (!_recording) + { + return; + } + + if (e.Key == VirtualKey.Escape) + { + StopRecording(); + e.Handled = true; + return; + } + + // A modifier on its own isn't a chord yet — keep listening. + if (IsModifierKey(e.Key)) + { + e.Handled = true; + return; + } + + var modifiers = ReadModifiers(); + var key = MapKey(e.Key); + + // Require a modifier and a supported key; otherwise swallow and wait. + if (modifiers == HotkeyModifiers.None || key is null) + { + e.Handled = true; + return; + } + + var binding = new HotkeyBinding { Modifiers = modifiers, Key = key }; + StopRecording(); + if (Command?.CanExecute(binding) == true) + { + Command.Execute(binding); + } + e.Handled = true; + } + + private void StartRecording() + { + _recording = true; + Label.Text = _recordingPrompt; + } + + private void StopRecording() + { + _recording = false; + UpdateLabel(); + } + + private void UpdateLabel() + { + if (!_recording) + { + Label.Text = string.IsNullOrEmpty(CurrentText) ? "(unbound)" : CurrentText; + } + } + + private static bool IsModifierKey(VirtualKey key) => + key is VirtualKey.Control or VirtualKey.LeftControl or VirtualKey.RightControl + or VirtualKey.Menu or VirtualKey.LeftMenu or VirtualKey.RightMenu + or VirtualKey.Shift or VirtualKey.LeftShift or VirtualKey.RightShift + or VirtualKey.LeftWindows or VirtualKey.RightWindows; + + private static bool IsDown(VirtualKey key) => + InputKeyboardSource.GetKeyStateForCurrentThread(key).HasFlag(CoreVirtualKeyStates.Down); + + private static HotkeyModifiers ReadModifiers() + { + var modifiers = HotkeyModifiers.None; + if (IsDown(VirtualKey.Control)) + { + modifiers |= HotkeyModifiers.Control; + } + if (IsDown(VirtualKey.Menu)) + { + modifiers |= HotkeyModifiers.Alt; + } + if (IsDown(VirtualKey.Shift)) + { + modifiers |= HotkeyModifiers.Shift; + } + if (IsDown(VirtualKey.LeftWindows) || IsDown(VirtualKey.RightWindows)) + { + modifiers |= HotkeyModifiers.Windows; + } + return modifiers; + } + + /// + /// Maps a virtual key to the Core key vocabulary the hotkey service + /// understands (A-Z, 0-9, F1-F12, Space, Tab, Enter). Returns null for + /// anything unsupported so the caller keeps listening. + /// + // Expressed as conditional chains rather than a switch over VirtualKey: + // that enum has hundreds of members, so a switch can satisfy neither the + // "populate every case" nor the "use a switch expression" analysers. + private static string? MapKey(VirtualKey key) => + key is >= VirtualKey.A and <= VirtualKey.Z ? key.ToString() + : key is >= VirtualKey.Number0 and <= VirtualKey.Number9 ? ((char)('0' + (key - VirtualKey.Number0))).ToString() + : key is >= VirtualKey.F1 and <= VirtualKey.F12 ? key.ToString() + : NamedKey(key); + + private static string? NamedKey(VirtualKey key) => + key == VirtualKey.Space ? "Space" + : key == VirtualKey.Tab ? "Tab" + : key == VirtualKey.Enter ? "Enter" + : null; + } +} diff --git a/src/Snipdeck.App/Converters/EmptyStringToVisibilityConverter.cs b/src/Snipdeck.App/Converters/EmptyStringToVisibilityConverter.cs new file mode 100644 index 0000000..a016a51 --- /dev/null +++ b/src/Snipdeck.App/Converters/EmptyStringToVisibilityConverter.cs @@ -0,0 +1,22 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Data; + +namespace Snipdeck.App.Converters +{ + /// + /// Visible when the bound string is non-empty, Collapsed otherwise — so + /// status/error text takes no layout space until there's something to show. + /// + public sealed partial class EmptyStringToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, string language) + { + return string.IsNullOrEmpty(value as string) ? Visibility.Collapsed : Visibility.Visible; + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Snipdeck.App/Views/ShellPage.xaml b/src/Snipdeck.App/Views/ShellPage.xaml index ec1b82d..55bd998 100644 --- a/src/Snipdeck.App/Views/ShellPage.xaml +++ b/src/Snipdeck.App/Views/ShellPage.xaml @@ -17,6 +17,7 @@ + @@ -167,9 +168,22 @@ - + Description="Click, then press a shortcut (needs Ctrl, Alt or Shift). Summons Snipdeck from anywhere."> + + + +