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); } /// /// 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. /// public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable { private readonly MessageTimer _pollTimer; private readonly Func _getForegroundWindow; private readonly Func _getActiveLocaleId; private readonly Action _requestNextLayout; private int _lastLocaleId = -1; private IntPtr _lastForegroundWindow; 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; _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(); _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; } bool appSwitched = foreground != _lastForegroundWindow; _lastForegroundWindow = foreground; int localeId = _getActiveLocaleId(); if (localeId == _lastLocaleId) { return; } _lastLocaleId = localeId; // 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)); } }