Files
cursor-lang/CursorLang.Agent/Services/CapsLockHotkeyService.cs
T
alex 55ac8e6556 lightweight variant (#1)
Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
2026-08-12 13:37:30 +00:00

168 lines
5.3 KiB
C#

using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Core.Threading;
namespace CursorLang.Agent.Services;
/// <summary>
/// Holds the system Caps Lock hook and tells a short tap from a hold.
/// </summary>
/// <remarks>
/// 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
/// <see cref="MessageTimer"/>, and the event reaches its subscribers through a message
/// posted to the agent's window rather than through the dispatcher.
/// </remarks>
public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
{
private const int VirtualKeyCapsLock = 0x14;
private readonly AppSettings _settings;
private readonly Action<Action> _post;
private readonly LowLevelKeyboardHook _hook;
private readonly MessageTimer _holdTimer = new();
private bool _isPressed;
private bool _isHolding;
/// <param name="settings">Where the hold threshold is read from, on every press.</param>
/// <param name="post">
/// 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.
/// </param>
public CapsLockHotkeyService(AppSettings settings, Action<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();
/// <summary>
/// The hold countdown has run out.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
}
}