using CursorLang.Core.Interop; using CursorLang.Core.Models; using CursorLang.Core.Threading; namespace CursorLang.Core.Services; /// /// The settings of layout tracking. /// public sealed class KeyboardLayoutOptions { /// How often to check the layout of the active window. public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(150); /// /// How long to wait for a layout that has just changed to stop changing. /// /// /// 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 /// . /// public TimeSpan SettleInterval { get; init; } = TimeSpan.FromMilliseconds(40); } /// /// Polls the active window on a timer. /// /// /// 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 rather /// than at the polling interval, so that tick is a few tens of milliseconds and not /// another whole poll. /// public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable { private readonly MessageTimer _pollTimer; private readonly Func _getForegroundWindow; private readonly Func _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) { } /// /// Takes the sources of system information explicitly: in tests the layout and the /// active window are not provided by Windows. /// internal KeyboardLayoutService( KeyboardLayoutOptions options, Func getForegroundWindow, Func getActiveLocaleId, Action requestNextLayout) { _getForegroundWindow = getForegroundWindow; _getActiveLocaleId = getActiveLocaleId; _requestNextLayout = requestNextLayout; _options = options; _pollTimer = new MessageTimer { Interval = options.PollInterval }; _pollTimer.Tick += OnTick; } public event EventHandler? 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(); } }