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.">
+
+
+
+
+
Modifiers == HotkeyModifiers.None && string.IsNullOrWhiteSpace(Key);
+ ///
+ /// A binding the platform can actually register: at least one modifier
+ /// plus a key. A bare key (no modifier) is rejected — global hotkeys
+ /// need a modifier so they don't swallow ordinary typing.
+ ///
+ public bool IsValid =>
+ Modifiers != HotkeyModifiers.None && !string.IsNullOrWhiteSpace(Key);
+
public static HotkeyBinding Default => new()
{
Modifiers = HotkeyModifiers.Control | HotkeyModifiers.Alt,
Key = "S",
};
+
+ /// Human-readable chord, e.g. "Ctrl+Alt+S" (or "(unbound)").
+ public string ToDisplayString()
+ {
+ if (IsEmpty)
+ {
+ return "(unbound)";
+ }
+ var parts = new List();
+ if (Modifiers.HasFlag(HotkeyModifiers.Control))
+ {
+ parts.Add("Ctrl");
+ }
+ if (Modifiers.HasFlag(HotkeyModifiers.Alt))
+ {
+ parts.Add("Alt");
+ }
+ if (Modifiers.HasFlag(HotkeyModifiers.Shift))
+ {
+ parts.Add("Shift");
+ }
+ if (Modifiers.HasFlag(HotkeyModifiers.Windows))
+ {
+ parts.Add("Win");
+ }
+ if (!string.IsNullOrWhiteSpace(Key))
+ {
+ parts.Add(Key);
+ }
+ return string.Join('+', parts);
+ }
}
}
diff --git a/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs
index ca96263..55fdb8f 100644
--- a/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs
+++ b/src/Snipdeck.Core/ViewModels/SettingsViewModel.cs
@@ -18,6 +18,7 @@ public sealed partial class SettingsViewModel : ObservableObject
private readonly ISettingsStore _settingsStore;
private readonly IThemeApplier _themeApplier;
private readonly IUpdateService _updateService;
+ private readonly IHotkeyService _hotkeyService;
private readonly AppConfig _config;
private bool _suppressPersist;
@@ -25,18 +26,21 @@ public SettingsViewModel(
ISettingsStore settingsStore,
IThemeApplier themeApplier,
IUpdateService updateService,
+ IHotkeyService hotkeyService,
IPathProvider pathProvider,
AppConfig config)
{
ArgumentNullException.ThrowIfNull(settingsStore);
ArgumentNullException.ThrowIfNull(themeApplier);
ArgumentNullException.ThrowIfNull(updateService);
+ ArgumentNullException.ThrowIfNull(hotkeyService);
ArgumentNullException.ThrowIfNull(pathProvider);
ArgumentNullException.ThrowIfNull(config);
_settingsStore = settingsStore;
_themeApplier = themeApplier;
_updateService = updateService;
+ _hotkeyService = hotkeyService;
_config = config;
_suppressPersist = true;
@@ -44,7 +48,7 @@ public SettingsViewModel(
ThemeIndex = ThemeIndexFor(config.Theme);
CloseBehaviour = config.CloseBehaviour;
CloseBehaviourIndex = CloseBehaviourIndexFor(config.CloseBehaviour);
- HotkeyDisplay = FormatHotkey(config.Hotkey);
+ HotkeyDisplay = config.Hotkey.ToDisplayString();
StorageDirectory = config.StoragePath ?? pathProvider.DefaultStorageDirectory;
BackupDirectory = config.BackupDirectory ?? pathProvider.DefaultBackupDirectory;
BackupRetention = config.BackupRetention;
@@ -72,7 +76,11 @@ public SettingsViewModel(
public string BackupDirectory { get; }
- public string HotkeyDisplay { get; }
+ [ObservableProperty]
+ public partial string HotkeyDisplay { get; set; } = string.Empty;
+
+ [ObservableProperty]
+ public partial string HotkeyError { get; set; } = string.Empty;
[ObservableProperty]
public partial ThemePreference Theme { get; set; }
@@ -155,6 +163,39 @@ private async Task ApplyUpdateAsync()
}
}
+ [RelayCommand]
+ private void RebindHotkey(HotkeyBinding? binding)
+ {
+ if (binding is null || !binding.IsValid)
+ {
+ HotkeyError = "Use at least one modifier (Ctrl, Alt or Shift) plus a key.";
+ return;
+ }
+
+ var previous = _config.Hotkey;
+ if (_hotkeyService.TryRegister(binding))
+ {
+ _config.Hotkey = binding;
+ HotkeyDisplay = binding.ToDisplayString();
+ HotkeyError = string.Empty;
+ _ = PersistAsync();
+ return;
+ }
+
+ // TryRegister unregisters the old binding before attempting the new
+ // one, so on failure (chord already taken by another app) the old
+ // hotkey is gone — restore it so the user isn't left with nothing.
+ _ = _hotkeyService.TryRegister(previous);
+ HotkeyDisplay = previous.ToDisplayString();
+ HotkeyError = $"Couldn't set {binding.ToDisplayString()} — it may already be in use by another app.";
+ }
+
+ [RelayCommand]
+ private void ResetHotkey()
+ {
+ RebindHotkey(HotkeyBinding.Default);
+ }
+
private async Task PersistAsync()
{
if (_suppressPersist)
@@ -164,6 +205,7 @@ private async Task PersistAsync()
_config.Theme = Theme;
_config.CloseBehaviour = CloseBehaviour;
_config.BackupRetention = BackupRetention;
+ // _config.Hotkey is set directly by RebindHotkey before this runs.
await _settingsStore.SaveAsync(_config).ConfigureAwait(true);
}
@@ -181,32 +223,5 @@ private async Task PersistAsync()
CloseBehaviour.Exit => 1,
_ => 0,
};
-
- private static string FormatHotkey(HotkeyBinding binding)
- {
- if (binding.IsEmpty)
- {
- return "(unbound)";
- }
- var parts = new List();
- if (binding.Modifiers.HasFlag(HotkeyModifiers.Control))
- {
- parts.Add("Ctrl");
- }
- if (binding.Modifiers.HasFlag(HotkeyModifiers.Alt))
- {
- parts.Add("Alt");
- }
- if (binding.Modifiers.HasFlag(HotkeyModifiers.Shift))
- {
- parts.Add("Shift");
- }
- if (binding.Modifiers.HasFlag(HotkeyModifiers.Windows))
- {
- parts.Add("Win");
- }
- parts.Add(binding.Key);
- return string.Join('+', parts);
- }
}
}
diff --git a/tests/Snipdeck.Core.Tests/Models/HotkeyBindingTests.cs b/tests/Snipdeck.Core.Tests/Models/HotkeyBindingTests.cs
new file mode 100644
index 0000000..83035e0
--- /dev/null
+++ b/tests/Snipdeck.Core.Tests/Models/HotkeyBindingTests.cs
@@ -0,0 +1,37 @@
+using Snipdeck.Core.Models;
+
+namespace Snipdeck.Core.Tests.Models
+{
+ public class HotkeyBindingTests
+ {
+ [Fact]
+ public void Default_is_ctrl_alt_s()
+ {
+ var d = HotkeyBinding.Default;
+ Assert.Equal(HotkeyModifiers.Control | HotkeyModifiers.Alt, d.Modifiers);
+ Assert.Equal("S", d.Key);
+ Assert.True(d.IsValid);
+ Assert.Equal("Ctrl+Alt+S", d.ToDisplayString());
+ }
+
+ [Fact]
+ public void IsValid_requires_a_modifier_and_a_key()
+ {
+ Assert.False(new HotkeyBinding { Modifiers = HotkeyModifiers.None, Key = "S" }.IsValid);
+ Assert.False(new HotkeyBinding { Modifiers = HotkeyModifiers.Control, Key = "" }.IsValid);
+ Assert.True(new HotkeyBinding { Modifiers = HotkeyModifiers.Control, Key = "S" }.IsValid);
+ }
+
+ [Fact]
+ public void ToDisplayString_orders_modifiers_and_handles_unbound()
+ {
+ Assert.Equal("(unbound)", new HotkeyBinding().ToDisplayString());
+ var all = new HotkeyBinding
+ {
+ Modifiers = HotkeyModifiers.Windows | HotkeyModifiers.Shift | HotkeyModifiers.Alt | HotkeyModifiers.Control,
+ Key = "F5",
+ };
+ Assert.Equal("Ctrl+Alt+Shift+Win+F5", all.ToDisplayString());
+ }
+ }
+}
diff --git a/tests/Snipdeck.Core.Tests/Support/FakeHotkeyService.cs b/tests/Snipdeck.Core.Tests/Support/FakeHotkeyService.cs
new file mode 100644
index 0000000..fb64011
--- /dev/null
+++ b/tests/Snipdeck.Core.Tests/Support/FakeHotkeyService.cs
@@ -0,0 +1,37 @@
+using Snipdeck.Core.Abstractions;
+using Snipdeck.Core.Models;
+
+namespace Snipdeck.Core.Tests.Support
+{
+ ///
+ /// Programmable double. Set
+ /// to control whether registration
+ /// "succeeds"; inspect / .
+ ///
+ public sealed class FakeHotkeyService : IHotkeyService
+ {
+ public bool NextRegisterResult { get; set; } = true;
+
+ public HotkeyBinding? LastRegistered { get; private set; }
+
+ public int RegisterCount { get; private set; }
+
+ public int UnregisterCount { get; private set; }
+
+#pragma warning disable CS0067 // Pressed is required by the interface but unused in tests.
+ public event EventHandler? Pressed;
+#pragma warning restore CS0067
+
+ public bool TryRegister(HotkeyBinding binding)
+ {
+ RegisterCount++;
+ if (NextRegisterResult)
+ {
+ LastRegistered = binding;
+ }
+ return NextRegisterResult;
+ }
+
+ public void Unregister() => UnregisterCount++;
+ }
+}
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/SettingsViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/SettingsViewModelTests.cs
new file mode 100644
index 0000000..6bac819
--- /dev/null
+++ b/tests/Snipdeck.Core.Tests/ViewModels/SettingsViewModelTests.cs
@@ -0,0 +1,90 @@
+using Snipdeck.Core.Models;
+using Snipdeck.Core.Tests.Support;
+using Snipdeck.Core.ViewModels;
+
+namespace Snipdeck.Core.Tests.ViewModels
+{
+ public class SettingsViewModelTests
+ {
+ private static SettingsViewModel Build(
+ out FakeHotkeyService hotkey,
+ out FakeSettingsStore store,
+ AppConfig? config = null)
+ {
+ hotkey = new FakeHotkeyService();
+ store = new FakeSettingsStore();
+ return new SettingsViewModel(
+ store,
+ new FakeThemeApplier(),
+ new FakeUpdateService(),
+ hotkey,
+ new FakePathProvider(),
+ config ?? new AppConfig());
+ }
+
+ [Fact]
+ public void Initial_hotkey_display_reflects_config()
+ {
+ var vm = Build(out _, out _, new AppConfig { Hotkey = HotkeyBinding.Default });
+ Assert.Equal("Ctrl+Alt+S", vm.HotkeyDisplay);
+ }
+
+ [Fact]
+ public void RebindHotkey_registers_persists_and_updates_display_on_success()
+ {
+ var vm = Build(out var hotkey, out var store);
+ var binding = new HotkeyBinding { Modifiers = HotkeyModifiers.Control | HotkeyModifiers.Shift, Key = "K" };
+
+ vm.RebindHotkeyCommand.Execute(binding);
+
+ Assert.Same(binding, hotkey.LastRegistered);
+ Assert.Equal("Ctrl+Shift+K", vm.HotkeyDisplay);
+ Assert.Equal(string.Empty, vm.HotkeyError);
+ Assert.Equal(1, store.SaveCount);
+ Assert.Equal(binding, store.Current.Hotkey);
+ }
+
+ [Fact]
+ public void RebindHotkey_rejects_a_binding_with_no_modifier()
+ {
+ var vm = Build(out var hotkey, out var store);
+
+ vm.RebindHotkeyCommand.Execute(new HotkeyBinding { Modifiers = HotkeyModifiers.None, Key = "K" });
+
+ Assert.Equal(0, hotkey.RegisterCount);
+ Assert.Equal(0, store.SaveCount);
+ Assert.NotEqual(string.Empty, vm.HotkeyError);
+ }
+
+ [Fact]
+ public void RebindHotkey_restores_previous_and_reports_error_when_registration_fails()
+ {
+ var vm = Build(out var hotkey, out _, new AppConfig { Hotkey = HotkeyBinding.Default });
+ hotkey.NextRegisterResult = false;
+
+ vm.RebindHotkeyCommand.Execute(new HotkeyBinding { Modifiers = HotkeyModifiers.Alt, Key = "F1" });
+
+ // The failed binding is not adopted; display reverts to the previous chord.
+ Assert.Equal("Ctrl+Alt+S", vm.HotkeyDisplay);
+ Assert.Contains("in use", vm.HotkeyError);
+ // Two register attempts: the (failed) new one, then the restore of the old.
+ Assert.Equal(2, hotkey.RegisterCount);
+ }
+
+ [Fact]
+ public void ResetHotkey_rebinds_to_the_default_chord()
+ {
+ var vm = Build(out var hotkey, out _, new AppConfig
+ {
+ Hotkey = new HotkeyBinding { Modifiers = HotkeyModifiers.Control | HotkeyModifiers.Shift, Key = "K" },
+ });
+
+ vm.ResetHotkeyCommand.Execute(null);
+
+ Assert.Equal("Ctrl+Alt+S", vm.HotkeyDisplay);
+ Assert.NotNull(hotkey.LastRegistered);
+ Assert.Equal(HotkeyBinding.Default.Modifiers, hotkey.LastRegistered!.Modifiers);
+ Assert.Equal("S", hotkey.LastRegistered.Key);
+ }
+ }
+}
diff --git a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs
index fc99c65..1332cc9 100644
--- a/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs
+++ b/tests/Snipdeck.Core.Tests/ViewModels/ShellViewModelTests.cs
@@ -29,6 +29,7 @@ private static SettingsViewModel NewSettingsViewModel()
new FakeSettingsStore(),
new FakeThemeApplier(),
new FakeUpdateService(),
+ new FakeHotkeyService(),
new FakePathProvider(),
new AppConfig());
}