This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
using CursorLang.Core.Interop;
|
||||
using CursorLang.Core.Models;
|
||||
using CursorLang.Core.Threading;
|
||||
|
||||
namespace CursorLang.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The settings of layout tracking.
|
||||
/// </summary>
|
||||
public sealed class KeyboardLayoutOptions
|
||||
{
|
||||
/// <summary>How often to check the layout of the active window.</summary>
|
||||
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(150);
|
||||
|
||||
/// <summary>
|
||||
/// How long to wait for a layout that has just changed to stop changing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Short on purpose: it is added to the delay before the popup shows, and it only
|
||||
/// applies while a switch is in flight. See the settling in
|
||||
/// <see cref="KeyboardLayoutService"/>.
|
||||
/// </remarks>
|
||||
public TimeSpan SettleInterval { get; init; } = TimeSpan.FromMilliseconds(40);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Polls the active window on a timer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Polling was chosen not for simplicity: there is no event-based way to learn about
|
||||
/// a layout change in another process from managed code. HSHELL_LANGUAGE from
|
||||
/// RegisterShellHookWindow does not arrive on Windows 10/11, and TSF notifications
|
||||
/// (ITfLanguageProfileNotifySink) report only language changes inside our own process
|
||||
/// — both options were tried and did not work. One tick is three Win32 calls reading
|
||||
/// data from kernel memory.
|
||||
///
|
||||
/// The timer ticks on the message loop of whatever thread starts it, the same as a
|
||||
/// dispatcher timer did: the agent has a plain Win32 loop and no dispatcher to offer.
|
||||
///
|
||||
/// A switch is not reported the moment it is first seen but once the value has stopped
|
||||
/// moving. A layout change is rarely a single step: the language switcher of Windows
|
||||
/// takes the focus while it is up, and applications with a rendering engine of their
|
||||
/// own change the layout of a helper thread before that of the input window. Polling
|
||||
/// catches those in-between values, and reporting them meant the popup appearing with
|
||||
/// one layout and turning into another in front of the user.
|
||||
///
|
||||
/// The price is that the popup comes up one short tick later. While a switch is in
|
||||
/// flight the timer runs at <see cref="KeyboardLayoutOptions.SettleInterval"/> rather
|
||||
/// than at the polling interval, so that tick is a few tens of milliseconds and not
|
||||
/// another whole poll.
|
||||
/// </remarks>
|
||||
public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable
|
||||
{
|
||||
private readonly MessageTimer _pollTimer;
|
||||
private readonly Func<IntPtr> _getForegroundWindow;
|
||||
private readonly Func<int> _getActiveLocaleId;
|
||||
private readonly Action _requestNextLayout;
|
||||
private readonly KeyboardLayoutOptions _options;
|
||||
|
||||
private int _lastLocaleId = -1;
|
||||
private IntPtr _lastForegroundWindow;
|
||||
private int _settlingLocaleId = -1;
|
||||
|
||||
public KeyboardLayoutService(KeyboardLayoutOptions options)
|
||||
: this(
|
||||
options,
|
||||
KeyboardLayoutNative.GetForegroundWindow,
|
||||
KeyboardLayoutNative.GetActiveLocaleId,
|
||||
KeyboardLayoutNative.RequestNextLayout)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the sources of system information explicitly: in tests the layout and the
|
||||
/// active window are not provided by Windows.
|
||||
/// </summary>
|
||||
internal KeyboardLayoutService(
|
||||
KeyboardLayoutOptions options,
|
||||
Func<IntPtr> getForegroundWindow,
|
||||
Func<int> getActiveLocaleId,
|
||||
Action requestNextLayout)
|
||||
{
|
||||
_getForegroundWindow = getForegroundWindow;
|
||||
_getActiveLocaleId = getActiveLocaleId;
|
||||
_requestNextLayout = requestNextLayout;
|
||||
_options = options;
|
||||
|
||||
_pollTimer = new MessageTimer { Interval = options.PollInterval };
|
||||
_pollTimer.Tick += OnTick;
|
||||
}
|
||||
|
||||
public event EventHandler<LayoutChangedEventArgs>? LayoutChanged;
|
||||
|
||||
public KeyboardLayout Current => KeyboardLayout.FromLocaleId(_getActiveLocaleId());
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_lastForegroundWindow = _getForegroundWindow();
|
||||
_lastLocaleId = _getActiveLocaleId();
|
||||
_settlingLocaleId = -1;
|
||||
_pollTimer.Interval = _options.PollInterval;
|
||||
_pollTimer.Start();
|
||||
}
|
||||
|
||||
public void Stop() => _pollTimer.Stop();
|
||||
|
||||
public void SwitchToNext() => _requestNextLayout();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_pollTimer.Tick -= OnTick;
|
||||
_pollTimer.Dispose();
|
||||
}
|
||||
|
||||
private void OnTick(object? sender, EventArgs e) => Poll();
|
||||
|
||||
// A single poll step. Called by the timer, and in tests — directly:
|
||||
// there is no point waiting for a tick to check how the reason for a layout
|
||||
// change is decided
|
||||
internal void Poll()
|
||||
{
|
||||
IntPtr foreground = _getForegroundWindow();
|
||||
if (foreground == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int localeId = _getActiveLocaleId();
|
||||
if (localeId == _lastLocaleId)
|
||||
{
|
||||
_lastForegroundWindow = foreground;
|
||||
Settle(inFlight: false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (localeId != _settlingLocaleId)
|
||||
{
|
||||
_settlingLocaleId = localeId;
|
||||
Settle(inFlight: true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
bool appSwitched = foreground != _lastForegroundWindow;
|
||||
|
||||
_lastLocaleId = localeId;
|
||||
_lastForegroundWindow = foreground;
|
||||
Settle(inFlight: false);
|
||||
|
||||
// Moving to another application with a layout of its own is not the same as
|
||||
// the user switching the layout, and the subscribers are free to react to
|
||||
// these cases differently
|
||||
LayoutChangeReason reason = appSwitched
|
||||
? LayoutChangeReason.ApplicationSwitched
|
||||
: LayoutChangeReason.UserSwitched;
|
||||
|
||||
LayoutChanged?.Invoke(this, new LayoutChangedEventArgs(KeyboardLayout.FromLocaleId(localeId), reason));
|
||||
}
|
||||
|
||||
// While a switch is in flight the next look comes sooner: that wait is added to the
|
||||
// delay before the popup, and a whole polling interval there would be felt
|
||||
private void Settle(bool inFlight)
|
||||
{
|
||||
if (!inFlight)
|
||||
{
|
||||
_settlingLocaleId = -1;
|
||||
}
|
||||
|
||||
TimeSpan wanted = inFlight ? _options.SettleInterval : _options.PollInterval;
|
||||
if (_pollTimer.Interval == wanted || !_pollTimer.IsRunning)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pollTimer.Interval = wanted;
|
||||
_pollTimer.Start();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user