diff --git a/CursorLang/Interop/CaretNative.cs b/CursorLang/Interop/CaretNative.cs index 278c917..5310d5d 100644 --- a/CursorLang/Interop/CaretNative.cs +++ b/CursorLang/Interop/CaretNative.cs @@ -15,29 +15,6 @@ namespace CursorLang.Interop; /// internal static class CaretNative { - [StructLayout(LayoutKind.Sequential)] - private struct GuiThreadInfo - { - public int cbSize; - public uint flags; - public IntPtr hwndActive; - public IntPtr hwndFocus; - public IntPtr hwndCapture; - public IntPtr hwndMenuOwner; - public IntPtr hwndMoveSize; - public IntPtr hwndCaret; - public PopupWindowNative.Rect rcCaret; - } - - [DllImport("user32.dll")] - private static extern bool GetGUIThreadInfo(uint idThread, ref GuiThreadInfo lpgui); - - [DllImport("user32.dll")] - private static extern IntPtr GetForegroundWindow(); - - [DllImport("user32.dll")] - private static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId); - [DllImport("user32.dll")] private static extern bool ClientToScreen(IntPtr hWnd, ref PopupWindowNative.Point lpPoint); @@ -45,6 +22,12 @@ internal static class CaretNative private static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint dwObjectId, ref Guid riid, out IAccessible ppvObject); + [DllImport("user32.dll")] + private static extern bool GetWindowRect(IntPtr hWnd, out PopupWindowNative.Rect lpRect); + + [DllImport("user32.dll")] + private static extern uint GetDpiForWindow(IntPtr hWnd); + private const uint OBJID_CARET = 0xFFFFFFF8; private const int CHILDID_SELF = 0; @@ -54,18 +37,55 @@ internal static class CaretNative /// internal static PopupWindowNative.Rect? TryGetCaretRect() { - var info = new GuiThreadInfo { cbSize = Marshal.SizeOf() }; - uint threadId = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero); - if (!GetGUIThreadInfo(threadId, ref info)) + if (!ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info)) { return null; } - return TryGetSystemCaret(info) + PopupWindowNative.Rect? caret = TryGetSystemCaret(info) ?? TryGetAccessibleCaret(info.hwndFocus) ?? TryGetAutomationCaret(); + + return caret is null ? null : Validate(caret.Value, info.hwndFocus); } + /// + /// Отсеивает заведомо неверные координаты. + /// + /// + /// Часть приложений отдаёт положение каретки в своей системе координат либо + /// без учёта масштаба экрана, и подсказка уезжает далеко от поля ввода. + /// Каретка обязана находиться внутри окна ввода — это и проверяем, а перед + /// отказом пробуем истолковать координаты как немасштабированные. + /// + private static PopupWindowNative.Rect? Validate(PopupWindowNative.Rect caret, IntPtr hwndFocus) + { + if (hwndFocus == IntPtr.Zero || !GetWindowRect(hwndFocus, out PopupWindowNative.Rect window)) + { + return caret; + } + + if (IsInside(caret, window)) + { + return caret; + } + + double scale = GetDpiForWindow(hwndFocus) / 96.0; + var scaled = new PopupWindowNative.Rect + { + Left = (int)(caret.Left * scale), + Top = (int)(caret.Top * scale), + Right = (int)(caret.Right * scale), + Bottom = (int)(caret.Bottom * scale), + }; + + return IsInside(scaled, window) ? scaled : null; + } + + private static bool IsInside(PopupWindowNative.Rect inner, PopupWindowNative.Rect outer) => + inner.Left >= outer.Left && inner.Right <= outer.Right && + inner.Top >= outer.Top && inner.Bottom <= outer.Bottom; + /// Сколько ждём ответа от чужого приложения по UI Automation. private static readonly TimeSpan AutomationTimeout = TimeSpan.FromMilliseconds(150); @@ -127,7 +147,7 @@ internal static class CaretNative } // Системная каретка: координаты приходят относительно окна, которому она принадлежит - private static PopupWindowNative.Rect? TryGetSystemCaret(GuiThreadInfo info) + private static PopupWindowNative.Rect? TryGetSystemCaret(ForegroundInputNative.GuiThreadInfo info) { if (info.hwndCaret == IntPtr.Zero || IsEmpty(info.rcCaret)) { diff --git a/CursorLang/Interop/ForegroundInputNative.cs b/CursorLang/Interop/ForegroundInputNative.cs new file mode 100644 index 0000000..574a518 --- /dev/null +++ b/CursorLang/Interop/ForegroundInputNative.cs @@ -0,0 +1,41 @@ +using System.Runtime.InteropServices; + +namespace CursorLang.Interop; + +/// +/// Сведения о вводе в активном приложении: какое окно держит фокус клавиатуры +/// и где находится каретка. +/// +internal static class ForegroundInputNative +{ + [StructLayout(LayoutKind.Sequential)] + internal struct GuiThreadInfo + { + public int cbSize; + public uint flags; + public IntPtr hwndActive; + public IntPtr hwndFocus; + public IntPtr hwndCapture; + public IntPtr hwndMenuOwner; + public IntPtr hwndMoveSize; + public IntPtr hwndCaret; + public PopupWindowNative.Rect rcCaret; + } + + [DllImport("user32.dll")] + private static extern bool GetGUIThreadInfo(uint idThread, ref GuiThreadInfo lpgui); + + /// + /// Состояние ввода потока переднего плана. + /// + /// + /// Нулевой идентификатор потока не случаен: у современных приложений окно + /// верхнего уровня и окно ввода живут в разных потоках, и спрашивать нужно + /// именно про передний план целиком. + /// + internal static bool TryGetInfo(out GuiThreadInfo info) + { + info = new GuiThreadInfo { cbSize = Marshal.SizeOf() }; + return GetGUIThreadInfo(0, ref info); + } +} diff --git a/CursorLang/Interop/KeyboardLayoutNative.cs b/CursorLang/Interop/KeyboardLayoutNative.cs index 8bf1927..d706d2e 100644 --- a/CursorLang/Interop/KeyboardLayoutNative.cs +++ b/CursorLang/Interop/KeyboardLayoutNative.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; namespace CursorLang.Interop; /// -/// Win32 API для определения раскладки активного окна. +/// Win32 API для определения раскладки активного приложения. /// internal static class KeyboardLayoutNative { @@ -16,6 +16,26 @@ internal static class KeyboardLayoutNative [DllImport("user32.dll")] private static extern IntPtr GetKeyboardLayout(uint idThread); + /// + /// Раскладка, которой сейчас печатает пользователь. + /// + /// + /// Спрашиваем окно с фокусом клавиатуры, а не окно верхнего плана: у Блокнота + /// Windows 11, меню «Пуск» и прочих приложений на WinUI поле ввода живёт в + /// отдельном потоке, и раскладка меняется только у него. У потока главного + /// окна она остаётся прежней, и переключение проходит незамеченным. + /// + internal static int GetActiveLocaleId() + { + if (ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info) && + info.hwndFocus != IntPtr.Zero) + { + return GetLocaleIdOf(info.hwndFocus); + } + + return GetLocaleIdOf(GetForegroundWindow()); + } + /// /// Идентификатор локали для окна. Раскладка в Windows привязана к потоку, /// поэтому так её видно у любого приложения, а не только у своего. diff --git a/CursorLang/Services/KeyboardLayoutService.cs b/CursorLang/Services/KeyboardLayoutService.cs index 1395d12..193bbb6 100644 --- a/CursorLang/Services/KeyboardLayoutService.cs +++ b/CursorLang/Services/KeyboardLayoutService.cs @@ -37,13 +37,12 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable public event EventHandler? LayoutChanged; - public KeyboardLayout Current => - KeyboardLayout.FromLocaleId(KeyboardLayoutNative.GetLocaleIdOf(KeyboardLayoutNative.GetForegroundWindow())); + public KeyboardLayout Current => KeyboardLayout.FromLocaleId(KeyboardLayoutNative.GetActiveLocaleId()); public void Start() { _lastForegroundWindow = KeyboardLayoutNative.GetForegroundWindow(); - _lastLocaleId = KeyboardLayoutNative.GetLocaleIdOf(_lastForegroundWindow); + _lastLocaleId = KeyboardLayoutNative.GetActiveLocaleId(); _pollTimer.Start(); } @@ -66,7 +65,7 @@ public sealed class KeyboardLayoutService : IKeyboardLayoutService, IDisposable bool appSwitched = foreground != _lastForegroundWindow; _lastForegroundWindow = foreground; - int localeId = KeyboardLayoutNative.GetLocaleIdOf(foreground); + int localeId = KeyboardLayoutNative.GetActiveLocaleId(); if (localeId == _lastLocaleId) { return;