This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Services;
|
||||
using CursorLang.Core.Threading;
|
||||
|
||||
namespace CursorLang.Agent.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the lifetime of the popup: the window is only responsible for showing it,
|
||||
/// while the decision of when to show and when to take it down is made here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same service it always was, with <c>DispatcherTimer</c> swapped for
|
||||
/// <see cref="MessageTimer"/>: both tick on the thread that owns the window, so
|
||||
/// nothing else about the logic had to move.
|
||||
/// </remarks>
|
||||
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
|
||||
{
|
||||
private readonly ILayoutPopupWindow _window;
|
||||
private readonly AppSettings _settings;
|
||||
private readonly MessageTimer _hideTimer = new();
|
||||
|
||||
public LayoutPopupService(ILayoutPopupWindow window, AppSettings settings)
|
||||
{
|
||||
_window = window;
|
||||
_settings = settings;
|
||||
|
||||
_hideTimer.Tick += OnHideTimerTick;
|
||||
}
|
||||
|
||||
public void Show(KeyboardLayout layout)
|
||||
{
|
||||
ShowUntilHidden(layout);
|
||||
|
||||
// The duration is read on every show: it is changed in the settings on the fly.
|
||||
// Restarting the timer also prolongs the show on quick switches
|
||||
_hideTimer.Interval = _settings.Duration;
|
||||
_hideTimer.Start();
|
||||
}
|
||||
|
||||
public void ShowUntilHidden(KeyboardLayout layout)
|
||||
{
|
||||
_hideTimer.Stop();
|
||||
_window.ShowPopup(layout.ShortName);
|
||||
}
|
||||
|
||||
public void Hide()
|
||||
{
|
||||
_hideTimer.Stop();
|
||||
_window.Hide();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_hideTimer.Tick -= OnHideTimerTick;
|
||||
_hideTimer.Dispose();
|
||||
_window.Close();
|
||||
}
|
||||
|
||||
private void OnHideTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
_hideTimer.Stop();
|
||||
_window.Hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using CursorLang.Core.Services;
|
||||
|
||||
namespace CursorLang.Agent.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the settings window.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The agent does not keep track of whether the window is already open, and does not
|
||||
/// need to: the settings process guards a single-instance slot of its own, so a second
|
||||
/// launch raises the window already there and exits. That costs a process start to find
|
||||
/// out, which is a fraction of the time it takes a person to look at the tray, and it
|
||||
/// saves the agent from holding a handle to something it does not own.
|
||||
/// </remarks>
|
||||
internal static class SettingsLauncher
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens the settings window. Returns <c>false</c> when the executable is not
|
||||
/// where it should be — a half-copied installation, or the agent run from a build
|
||||
/// folder of its own.
|
||||
/// </summary>
|
||||
internal static bool Open()
|
||||
{
|
||||
if (AgentExecutable.SettingsPath is not { } path)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using Process? started = Process.Start(new ProcessStartInfo(path) { UseShellExecute = false });
|
||||
return started is not null;
|
||||
}
|
||||
catch (Exception e) when (e is Win32Exception or InvalidOperationException)
|
||||
{
|
||||
// Nothing to tell the user with: the agent has no window of its own, and
|
||||
// the one that would have shown the message is the one that failed to start
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user