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..dd5db4a --- /dev/null +++ b/src/Snipdeck.App/Controls/HotkeyCaptureBox.xaml.cs @@ -0,0 +1,198 @@ +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) + { + // Keyboard activation: Enter or Space begins recording (pointer + // users start it by tapping). Every other key passes through so + // Tab navigation through Settings still works. + if (e.Key is VirtualKey.Enter or VirtualKey.Space) + { + StartRecording(); + e.Handled = true; + } + 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(); + if (modifiers == HotkeyModifiers.None) + { + // A bare key while recording (e.g. Tab to move on) — cancel and + // let it act normally so keyboard focus isn't trapped in the box. + StopRecording(); + return; + } + + var key = MapKey(e.Key); + if (key is null) + { + // Modifiers held but an unsupported key — swallow and keep waiting. + 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."> + + + +