using System.Windows.Threading; using CursorLang.Interop; using CursorLang.Models; namespace CursorLang.Services; /// /// Holds the system Caps Lock hook and tells a short tap from a hold. /// /// /// They can be told apart only by the key being released, so both events are /// intercepted — the press and the release. That is also the only way to cancel the /// case change: Windows toggles Caps Lock on the press event, and letting it through /// "just in case" is not an option. /// public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable { private const int VirtualKeyCapsLock = 0x14; private readonly AppSettings _settings; private readonly LowLevelKeyboardHook _hook; private readonly DispatcherTimer _holdTimer = new(); private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher; private bool _isPressed; private bool _isHolding; public CapsLockHotkeyService(AppSettings settings) { _settings = settings; _hook = new LowLevelKeyboardHook(HandleKeyEvent); _holdTimer.Tick += OnHoldTimerTick; } public event EventHandler? Tapped; public event EventHandler? HoldStarted; public event EventHandler? HoldEnded; public bool IsRunning => _hook.IsInstalled; public void Start() => _hook.Install(); public void Stop() { _hook.Uninstall(); ResetPress(); } // When the application is closing, nobody is waiting for events any more, so // unlike in Stop the state is reset quietly: the dispatcher queue may be shut // down by that moment public void Dispose() { _holdTimer.Tick -= OnHoldTimerTick; _holdTimer.Stop(); _isPressed = false; _isHolding = false; _hook.Dispose(); } // Called by the system hook, that is, inside message queue processing. Only state // tracking belongs here: showing windows and raising events from here is not // allowed — the handler must return control within a few milliseconds. // In tests the key presses are fed here as well: there is no need to install a // real keyboard hook just to check how presses are interpreted internal bool HandleKeyEvent(int virtualKey, bool isKeyDown) { if (virtualKey != VirtualKeyCapsLock) { return false; } if (isKeyDown) { // While the key is held, Windows repeats the press: the hold is counted // from the first event and the repeats are ignored if (!_isPressed) { _isPressed = true; // The threshold is read on every press: it is changed in the settings on the fly _holdTimer.Interval = _settings.CapsLockHoldDelay; _holdTimer.Start(); } return true; } _isPressed = false; _holdTimer.Stop(); if (_isHolding) { _isHolding = false; Notify(HoldEnded); } else { Notify(Tapped); } return true; } private void OnHoldTimerTick(object? sender, EventArgs e) { _holdTimer.Stop(); _isHolding = true; HoldStarted?.Invoke(this, EventArgs.Empty); } // The event reaches the subscribers after the hook returns: they are free to show // windows and do anything else without holding up the handling of the key press private void Notify(EventHandler? handler) { if (handler is not null) { _dispatcher.BeginInvoke(() => handler(this, EventArgs.Empty)); } } // The hook may have been removed with the key held down — by clearing the // checkbox in the settings, for instance. The popup has to be taken down then private void ResetPress() { _isPressed = false; _holdTimer.Stop(); if (_isHolding) { _isHolding = false; Notify(HoldEnded); } } }