using CursorLang.Core.Interop; using CursorLang.Core.Models; using CursorLang.Core.Services; using CursorLang.Core.Threading; namespace CursorLang.Agent.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. /// /// Two things changed on the way out of WPF: the hold is timed by /// , and the event reaches its subscribers through a message /// posted to the agent's window rather than through the dispatcher. /// public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable { private const int VirtualKeyCapsLock = 0x14; private readonly AppSettings _settings; private readonly Action _post; private readonly LowLevelKeyboardHook _hook; private readonly MessageTimer _holdTimer = new(); private bool _isPressed; private bool _isHolding; /// Where the hold threshold is read from, on every press. /// /// Hands work back to the message loop. Taken as a delegate rather than as the /// agent's window so that the press logic can be checked without one. /// public CapsLockHotkeyService(AppSettings settings, Action post) { _settings = settings; _post = post; _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 message loop is already gone by // that moment and posted work would never run public void Dispose() { _holdTimer.Tick -= OnHoldTimerTick; _holdTimer.Dispose(); _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) => HandleHoldElapsed(); /// /// The hold countdown has run out. /// /// /// The key being down is checked rather than assumed. A countdown started on the /// press can still be delivered just after the release — Windows does not withdraw a /// WM_TIMER it has already posted — and announcing a hold then would put the popup on /// screen showing the layout the tap is about to change away from. /// /// The tests reach this directly: that ordering is the whole point and a real clock /// will not reproduce it on demand. /// internal void HandleHoldElapsed() { _holdTimer.Stop(); if (!_isPressed) { return; } _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) { _post(() => 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); } } }