From b4d980e018de08ef7f4153fab67ae292b2bbd71c Mon Sep 17 00:00:00 2001 From: Dennis van der Stelt Date: Wed, 15 Jul 2026 08:59:29 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Make=20global=20shortcuts=20con?= =?UTF-8?q?figurable=20and=20refresh=20the=20settings=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-action rebinding of the toggle, clear, and drawing-mode hotkeys through an in-settings click-to-record field, each with a small reset button, plus a tray toggle for the keyboard-shortcut display. Replace the color swatch rows with compact color dropdowns. --- src/ClickLightWin.Tests/HotKeyBindingTests.cs | 53 +++++ src/ClickLightWin/AppController.cs | 31 ++- src/ClickLightWin/HotKeyBinding.cs | 65 +++++++ src/ClickLightWin/Interop/HotKeyManager.cs | 63 ++++-- src/ClickLightWin/Palette.cs | 20 +- src/ClickLightWin/Settings.cs | 7 + src/ClickLightWin/Tray/TrayIcon.cs | 4 + src/ClickLightWin/Views/SettingsWindow.xaml | 183 +++++++++++------- .../Views/SettingsWindow.xaml.cs | 11 ++ src/ClickLightWin/Views/ShortcutRecorder.cs | 79 ++++++++ 10 files changed, 410 insertions(+), 106 deletions(-) create mode 100644 src/ClickLightWin.Tests/HotKeyBindingTests.cs create mode 100644 src/ClickLightWin/HotKeyBinding.cs create mode 100644 src/ClickLightWin/Views/ShortcutRecorder.cs diff --git a/src/ClickLightWin.Tests/HotKeyBindingTests.cs b/src/ClickLightWin.Tests/HotKeyBindingTests.cs new file mode 100644 index 0000000..f3bbad5 --- /dev/null +++ b/src/ClickLightWin.Tests/HotKeyBindingTests.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Windows.Input; +using ClickLightWin.Interop; + +namespace ClickLightWin.Tests; + +public class HotKeyBindingTests +{ + [Fact] + public void Display_formats_modifiers_and_key() + { + var b = new HotKeyBinding(ModifierKeys.Control | ModifierKeys.Shift, Key.L); + Assert.Equal("Ctrl+Shift+L", b.Display); + } + + [Fact] + public void Display_orders_ctrl_alt_shift_win_and_names_digits_and_fkeys() + { + Assert.Equal("Ctrl+Alt+5", new HotKeyBinding(ModifierKeys.Control | ModifierKeys.Alt, Key.D5).Display); + Assert.Equal("Ctrl+F5", new HotKeyBinding(ModifierKeys.Control, Key.F5).Display); + Assert.Equal("Win+D", new HotKeyBinding(ModifierKeys.Windows, Key.D).Display); + } + + [Fact] + public void Win32_modifiers_map_and_always_include_no_repeat() + { + var b = new HotKeyBinding(ModifierKeys.Control | ModifierKeys.Shift, Key.L); + var expected = NativeMethods.MOD_CONTROL | NativeMethods.MOD_SHIFT | NativeMethods.MOD_NOREPEAT; + Assert.Equal(expected, b.Win32Modifiers); + } + + [Fact] + public void VirtualKey_matches_the_win32_code() + { + Assert.Equal(0x4Cu, new HotKeyBinding(ModifierKeys.Control, Key.L).VirtualKey); // 'L' + } + + [Fact] + public void Requires_a_key_and_a_non_shift_modifier_to_be_valid() + { + Assert.True(new HotKeyBinding(ModifierKeys.Control, Key.L).IsValid); + Assert.False(new HotKeyBinding(ModifierKeys.Shift, Key.L).IsValid); // Shift alone is typing + Assert.False(new HotKeyBinding(ModifierKeys.Control, Key.None).IsValid); + } + + [Fact] + public void Round_trips_through_json() + { + var b = new HotKeyBinding(ModifierKeys.Control | ModifierKeys.Alt, Key.K); + var restored = JsonSerializer.Deserialize(JsonSerializer.Serialize(b)); + Assert.Equal(b, restored); + } +} diff --git a/src/ClickLightWin/AppController.cs b/src/ClickLightWin/AppController.cs index 339b26b..e277dc4 100644 --- a/src/ClickLightWin/AppController.cs +++ b/src/ClickLightWin/AppController.cs @@ -57,12 +57,12 @@ public void Start() // One overlay per monitor; rebuilds itself on display changes. _overlays = new OverlayManager(_settings); - // Global hotkeys (Ctrl+Shift+L toggle, Ctrl+Shift+C clear, Ctrl+Shift+D draw mode). + // Global hotkeys (defaults Ctrl+Shift+L/C/D; user-configurable in settings). _hotKeys.TogglePressed += ToggleEnabled; _hotKeys.ClearPressed += ClearAnnotations; _hotKeys.DrawModePressed += ToggleDrawMode; - _hotKeys.Register(); - WarnAboutUnavailableHotkeys(); + _hotKeys.Start(); + ConfigureHotkeys(); // Install the system-wide mouse hook on the UI thread so the callback fires // here and can touch the overlays without cross-thread marshaling. @@ -88,17 +88,23 @@ private void UpdateKeyboardHook() // The laser features (glow move stream, Ctrl+drag stroke) run only while the laser is on. private bool LaserActive => _settings.Enabled && _settings.ShowLaserPointer; - // If another app owns a hotkey, RegisterHotKey fails silently; tell the user - // once so a dead shortcut is not mistaken for a ClickLight bug. + private void ConfigureHotkeys() + { + _hotKeys.Configure(_settings.ToggleHotKey, _settings.ClearHotKey, _settings.DrawModeHotKey); + WarnAboutUnavailableHotkeys(); + } + + // If a binding is invalid or another app owns it, RegisterHotKey fails silently; + // tell the user once so a dead shortcut is not mistaken for a ClickLight bug. private void WarnAboutUnavailableHotkeys() { var taken = new List(); - if (!_hotKeys.ToggleRegistered) taken.Add("Ctrl+Shift+L (toggle)"); - if (!_hotKeys.ClearRegistered) taken.Add("Ctrl+Shift+C (clear annotations)"); - if (!_hotKeys.DrawModeRegistered) taken.Add("Ctrl+Shift+D (drawing mode)"); + if (!_hotKeys.ToggleRegistered) taken.Add($"{_settings.ToggleHotKey.Display} (toggle)"); + if (!_hotKeys.ClearRegistered) taken.Add($"{_settings.ClearHotKey.Display} (clear annotations)"); + if (!_hotKeys.DrawModeRegistered) taken.Add($"{_settings.DrawModeHotKey.Display} (drawing mode)"); if (taken.Count == 0) return; - _tray?.ShowWarning("ClickLight hotkey unavailable", - $"Another app already uses {string.Join(" and ", taken)}. That shortcut won't work while it does."); + _tray?.ShowWarning("ClickLight shortcut unavailable", + $"{string.Join(" and ", taken)} could not be registered (already in use or invalid)."); } // Left-drag draws an arrow, right-drag a box; the hook tags each with its tool. @@ -167,10 +173,15 @@ private void ShowSettings() return; } + // Suspend the global hotkeys while configuring, so pressing a combo in the + // shortcut recorder neither fires an action nor collides with the OS registration. + _hotKeys.Suspend(); + _settingsWindow = new SettingsWindow(_settings); _settingsWindow.Closed += (_, _) => { _settingsWindow = null; + ConfigureHotkeys(); // apply any rebindings and re-enable the hotkeys _settingsStore.Save(_settings); // persist any edits made in the window }; _settingsWindow.Show(); diff --git a/src/ClickLightWin/HotKeyBinding.cs b/src/ClickLightWin/HotKeyBinding.cs new file mode 100644 index 0000000..8df3ebb --- /dev/null +++ b/src/ClickLightWin/HotKeyBinding.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using System.Windows.Input; +using ClickLightWin.Interop; + +namespace ClickLightWin; + +/// +/// A user-configurable global hotkey: a set of modifiers plus a key. Stored in +/// WPF terms (so the recorder and display are simple) and converted to Win32 +/// modifiers/virtual-key for RegisterHotKey. Maps to HotKeyBinding.swift. +/// +public sealed record HotKeyBinding(ModifierKeys Modifiers, Key Key) +{ + /// A hotkey needs a key and at least one of Ctrl/Alt/Win to be a sane global shortcut. + [JsonIgnore] + public bool IsValid => Key != Key.None + && (Modifiers & (ModifierKeys.Control | ModifierKeys.Alt | ModifierKeys.Windows)) != 0; + + [JsonIgnore] + public uint Win32Modifiers + { + get + { + var m = NativeMethods.MOD_NOREPEAT; + if (Modifiers.HasFlag(ModifierKeys.Control)) m |= NativeMethods.MOD_CONTROL; + if (Modifiers.HasFlag(ModifierKeys.Alt)) m |= NativeMethods.MOD_ALT; + if (Modifiers.HasFlag(ModifierKeys.Shift)) m |= NativeMethods.MOD_SHIFT; + if (Modifiers.HasFlag(ModifierKeys.Windows)) m |= NativeMethods.MOD_WIN; + return m; + } + } + + [JsonIgnore] public uint VirtualKey => (uint)KeyInterop.VirtualKeyFromKey(Key); + + /// Human-readable form, e.g. "Ctrl+Shift+L". + [JsonIgnore] + public string Display + { + get + { + var parts = new List(4); + if (Modifiers.HasFlag(ModifierKeys.Control)) parts.Add("Ctrl"); + if (Modifiers.HasFlag(ModifierKeys.Alt)) parts.Add("Alt"); + if (Modifiers.HasFlag(ModifierKeys.Shift)) parts.Add("Shift"); + if (Modifiers.HasFlag(ModifierKeys.Windows)) parts.Add("Win"); + parts.Add(KeyName(Key)); + return string.Join("+", parts); + } + } + + /// The out-of-the-box binding for each action (also used by the reset buttons). + public static readonly HotKeyBinding DefaultToggle = new(ModifierKeys.Control | ModifierKeys.Shift, Key.L); + public static readonly HotKeyBinding DefaultClear = new(ModifierKeys.Control | ModifierKeys.Shift, Key.C); + public static readonly HotKeyBinding DefaultDrawMode = new(ModifierKeys.Control | ModifierKeys.Shift, Key.D); + + private static string KeyName(Key key) => key switch + { + >= Key.A and <= Key.Z => key.ToString(), + >= Key.D0 and <= Key.D9 => key.ToString()[1..], // "D5" -> "5" + >= Key.NumPad0 and <= Key.NumPad9 => "Num" + key.ToString()[6..], + >= Key.F1 and <= Key.F24 => key.ToString(), + Key.None => "?", + _ => key.ToString() + }; +} diff --git a/src/ClickLightWin/Interop/HotKeyManager.cs b/src/ClickLightWin/Interop/HotKeyManager.cs index db4073c..6a1d821 100644 --- a/src/ClickLightWin/Interop/HotKeyManager.cs +++ b/src/ClickLightWin/Interop/HotKeyManager.cs @@ -4,37 +4,33 @@ namespace ClickLightWin; /// -/// Registers the system-wide hotkeys on a message-only window: Ctrl+Shift+L to -/// toggle ClickLight and Ctrl+Shift+C to clear annotations. The Windows analogue -/// of HotKeyManager.swift, which uses Carbon RegisterEventHotKey on macOS. +/// Registers the configurable global hotkeys (toggle, clear annotations, drawing +/// mode) on a message-only window and raises an event when one fires. Bindings can +/// be reconfigured at runtime, and suspended while the settings window is open so +/// they neither fire nor collide with the shortcut recorder. Maps to HotKeyManager.swift. /// public sealed class HotKeyManager : IDisposable { private const int ToggleHotKeyId = 1; private const int ClearHotKeyId = 2; private const int DrawModeHotKeyId = 3; - private const uint VkL = 0x4C; // 'L' - private const uint VkC = 0x43; // 'C' - private const uint VkD = 0x44; // 'D' private HwndSource? _source; + private HotKeyBinding? _toggle, _clear, _drawMode; public event Action? TogglePressed; public event Action? ClearPressed; public event Action? DrawModePressed; - /// False when another application already owns the combination. + /// False when the binding is invalid or another application already owns it. public bool ToggleRegistered { get; private set; } - - /// False when another application already owns the combination. public bool ClearRegistered { get; private set; } - - /// False when another application already owns the combination. public bool DrawModeRegistered { get; private set; } - public void Register() + /// Create the message-only window that receives WM_HOTKEY. + public void Start() { - // A message-only window receives WM_HOTKEY without a visible or taskbar window. + if (_source is not null) return; var parameters = new HwndSourceParameters("ClickLightWinHotKeyWindow") { Width = 0, @@ -43,11 +39,40 @@ public void Register() }; _source = new HwndSource(parameters); _source.AddHook(WndProc); + } + + /// Apply a new set of bindings and (re)register them. + public void Configure(HotKeyBinding toggle, HotKeyBinding clear, HotKeyBinding drawMode) + { + _toggle = toggle; + _clear = clear; + _drawMode = drawMode; + Reregister(); + } + + /// Unregister everything (e.g. while the settings window records a new combo). + public void Suspend() => UnregisterAll(); - const uint mod = NativeMethods.MOD_CONTROL | NativeMethods.MOD_SHIFT | NativeMethods.MOD_NOREPEAT; - ToggleRegistered = NativeMethods.RegisterHotKey(_source.Handle, ToggleHotKeyId, mod, VkL); - ClearRegistered = NativeMethods.RegisterHotKey(_source.Handle, ClearHotKeyId, mod, VkC); - DrawModeRegistered = NativeMethods.RegisterHotKey(_source.Handle, DrawModeHotKeyId, mod, VkD); + private void Reregister() + { + UnregisterAll(); + if (_source is null) return; + ToggleRegistered = TryRegister(ToggleHotKeyId, _toggle); + ClearRegistered = TryRegister(ClearHotKeyId, _clear); + DrawModeRegistered = TryRegister(DrawModeHotKeyId, _drawMode); + } + + private bool TryRegister(int id, HotKeyBinding? binding) => + binding is { IsValid: true } + && NativeMethods.RegisterHotKey(_source!.Handle, id, binding.Win32Modifiers, binding.VirtualKey); + + private void UnregisterAll() + { + if (_source is null) return; + NativeMethods.UnregisterHotKey(_source.Handle, ToggleHotKeyId); + NativeMethods.UnregisterHotKey(_source.Handle, ClearHotKeyId); + NativeMethods.UnregisterHotKey(_source.Handle, DrawModeHotKeyId); + ToggleRegistered = ClearRegistered = DrawModeRegistered = false; } private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled) @@ -65,9 +90,7 @@ private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool hand public void Dispose() { if (_source is null) return; - if (ToggleRegistered) NativeMethods.UnregisterHotKey(_source.Handle, ToggleHotKeyId); - if (ClearRegistered) NativeMethods.UnregisterHotKey(_source.Handle, ClearHotKeyId); - if (DrawModeRegistered) NativeMethods.UnregisterHotKey(_source.Handle, DrawModeHotKeyId); + UnregisterAll(); _source.RemoveHook(WndProc); _source.Dispose(); _source = null; diff --git a/src/ClickLightWin/Palette.cs b/src/ClickLightWin/Palette.cs index 9b02eb8..11b8892 100644 --- a/src/ClickLightWin/Palette.cs +++ b/src/ClickLightWin/Palette.cs @@ -5,15 +5,17 @@ namespace ClickLightWin; -/// One selectable color, carrying both its hex (persisted) and a frozen brush (for the swatch). +/// One selectable color, carrying its hex (persisted), a display name, and a frozen brush. public sealed class ColorSwatch { public string Hex { get; } + public string Name { get; } public Brush Brush { get; } - public ColorSwatch(string hex) + public ColorSwatch(string hex, string name) { Hex = hex; + Name = name; var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); brush.Freeze(); Brush = brush; @@ -23,17 +25,17 @@ public ColorSwatch(string hex) /// /// The fixed palette of pulse colors offered per button in the settings window. /// The first three entries are the default left/right/middle colors, so a fresh -/// install shows a selected swatch in each row. +/// install shows a selected swatch in each dropdown. /// public static class Palette { public static readonly ColorSwatch[] Colors = [ - new("#3B82F6"), // blue (default left) - new("#F97316"), // orange (default right) - new("#22C55E"), // green (default middle) - new("#EF4444"), // red - new("#A855F7"), // purple - new("#06B6D4") // cyan + new("#3B82F6", "Blue"), // default left + new("#F97316", "Orange"), // default right + new("#22C55E", "Green"), // default middle + new("#EF4444", "Red"), + new("#A855F7", "Purple"), + new("#06B6D4", "Cyan") ]; } diff --git a/src/ClickLightWin/Settings.cs b/src/ClickLightWin/Settings.cs index 76e006f..404aba7 100644 --- a/src/ClickLightWin/Settings.cs +++ b/src/ClickLightWin/Settings.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Text.Json.Serialization; using System.Windows; +using System.Windows.Input; using Color = System.Windows.Media.Color; using Colors = System.Windows.Media.Colors; @@ -29,6 +30,9 @@ public sealed class Settings : INotifyPropertyChanged private string _rightColorHex = "#F97316"; // orange private string _middleColorHex = "#22C55E"; // green private string _annotationColorHex = "#EF4444"; // red (shared by arrows and boxes) + private HotKeyBinding _toggleHotKey = HotKeyBinding.DefaultToggle; + private HotKeyBinding _clearHotKey = HotKeyBinding.DefaultClear; + private HotKeyBinding _drawModeHotKey = HotKeyBinding.DefaultDrawMode; // ---- Persisted, user-editable ------------------------------------------- @@ -47,6 +51,9 @@ public sealed class Settings : INotifyPropertyChanged public string RightColorHex { get => _rightColorHex; set => Set(ref _rightColorHex, value); } public string MiddleColorHex { get => _middleColorHex; set => Set(ref _middleColorHex, value); } public string AnnotationColorHex { get => _annotationColorHex; set => Set(ref _annotationColorHex, value); } + public HotKeyBinding ToggleHotKey { get => _toggleHotKey; set => Set(ref _toggleHotKey, value); } + public HotKeyBinding ClearHotKey { get => _clearHotKey; set => Set(ref _clearHotKey, value); } + public HotKeyBinding DrawModeHotKey { get => _drawModeHotKey; set => Set(ref _drawModeHotKey, value); } // ---- Computed render constants (not persisted, not user-editable yet) ---- diff --git a/src/ClickLightWin/Tray/TrayIcon.cs b/src/ClickLightWin/Tray/TrayIcon.cs index 49d4a56..e778649 100644 --- a/src/ClickLightWin/Tray/TrayIcon.cs +++ b/src/ClickLightWin/Tray/TrayIcon.cs @@ -24,6 +24,7 @@ public sealed class TrayIcon : IDisposable private readonly ToolStripMenuItem _laserItem; private readonly ToolStripMenuItem _releaseItem; private readonly ToolStripMenuItem _dragItem; + private readonly ToolStripMenuItem _shortcutsItem; private readonly ToolStripMenuItem _launchItem; private readonly List<(ToolStripMenuItem Item, double Value)> _sizeItems = []; private readonly List<(ToolStripMenuItem Item, double Value)> _durationItems = []; @@ -48,6 +49,7 @@ public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin, _laserItem = Check("Laser pointer mode", () => Toggle(s => s.ShowLaserPointer = !s.ShowLaserPointer)); _releaseItem = Check("Show release ring", () => Toggle(s => s.ShowRelease = !s.ShowRelease)); _dragItem = Check("Show drag trail", () => Toggle(s => s.ShowDrag = !s.ShowDrag)); + _shortcutsItem = Check("Show keyboard shortcuts", () => Toggle(s => s.ShowShortcuts = !s.ShowShortcuts)); _launchItem = Check("Launch at login", () => _launchAtLogin.SetEnabled(!_launchAtLogin.IsEnabled)); menu.Items.Add(_enabledItem); @@ -55,6 +57,7 @@ public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin, menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(_releaseItem); menu.Items.Add(_dragItem); + menu.Items.Add(_shortcutsItem); menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(PresetSubmenu("Size", Presets.Sizes, _sizeItems, v => _settings.BaseDiameterDips = v)); menu.Items.Add(PresetSubmenu("Duration", Presets.Durations, _durationItems, v => _settings.PulseDurationMs = v)); @@ -134,6 +137,7 @@ private void RefreshChecks() _laserItem.Checked = _settings.ShowLaserPointer; _releaseItem.Checked = _settings.ShowRelease; _dragItem.Checked = _settings.ShowDrag; + _shortcutsItem.Checked = _settings.ShowShortcuts; _launchItem.Checked = _launchAtLogin.IsEnabled; foreach (var (item, value) in _sizeItems) item.Checked = Near(_settings.BaseDiameterDips, value); foreach (var (item, value) in _durationItems) item.Checked = Near(_settings.PulseDurationMs, value); diff --git a/src/ClickLightWin/Views/SettingsWindow.xaml b/src/ClickLightWin/Views/SettingsWindow.xaml index fef4365..fde2a8f 100644 --- a/src/ClickLightWin/Views/SettingsWindow.xaml +++ b/src/ClickLightWin/Views/SettingsWindow.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:ClickLightWin" + xmlns:views="clr-namespace:ClickLightWin.Views" Title="ClickLight Settings" Width="400" SizeToContent="Height" @@ -92,44 +93,46 @@ - - + + + + + + + + + + + + + + @@ -143,6 +146,33 @@ + + + @@ -192,25 +222,27 @@ SelectedValue="{Binding PulseDurationMs, Mode=TwoWay}" Margin="0,0,0,20" /> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + @@ -230,17 +262,34 @@ - - - - - - - - - - + + + + + + + + + + + + + +