using System.Runtime.InteropServices; using System.Windows.Automation; using System.Windows.Automation.Text; using Accessibility; namespace CursorLang.Interop; /// /// Определяет положение каретки в активном поле ввода — в том числе в чужом приложении. /// /// /// Единого способа нет: классические Win32-приложения заводят системную каретку, /// а Chrome, Electron и прочие рисуют её сами и сообщают положение только через /// средства доступности. Поэтому сначала спрашиваем систему, затем — приложение. /// internal static class CaretNative { [DllImport("user32.dll")] private static extern bool ClientToScreen(IntPtr hWnd, ref PopupWindowNative.Point lpPoint); [DllImport("oleacc.dll")] 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; /// /// Прямоугольник каретки в пикселях экрана или null, если активное /// приложение не сообщает её положение. /// internal static PopupWindowNative.Rect? TryGetCaretRect() { if (!ForegroundInputNative.TryGetInfo(out ForegroundInputNative.GuiThreadInfo info)) { return null; } 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); // Браузеры и другие приложения на своих движках рисуют каретку сами и // сообщают её положение только через UI Automation. Запрос идёт в чужой // процесс, поэтому он самый медленный и стоит последним private static PopupWindowNative.Rect? TryGetAutomationCaret() { // Зависшее приложение не должно подвешивать подсказку вместе с собой: // ждём ответ ограниченное время, иначе показываем у курсора Task query = Task.Run(QueryAutomationCaret); return query.Wait(AutomationTimeout) ? query.Result : null; } private static PopupWindowNative.Rect? QueryAutomationCaret() { try { AutomationElement focused = AutomationElement.FocusedElement; if (focused is null || !focused.TryGetCurrentPattern(TextPattern.Pattern, out object pattern)) { return null; } TextPatternRange[] selection = ((TextPattern)pattern).GetSelection(); if (selection.Length == 0) { return null; } // У каретки выделение пустое и прямоугольника не имеет, // поэтому расширяем его до ближайшего символа TextPatternRange range = selection[0].Clone(); range.ExpandToEnclosingUnit(TextUnit.Character); System.Windows.Rect[] rectangles = range.GetBoundingRectangles(); if (rectangles.Length == 0 || rectangles[0].Height <= 0) { return null; } System.Windows.Rect caret = rectangles[0]; return new PopupWindowNative.Rect { Left = (int)caret.Left, Top = (int)caret.Top, Right = (int)caret.Right, Bottom = (int)caret.Bottom, }; } catch (Exception e) when (e is ElementNotAvailableException or InvalidOperationException or COMException) { // Приложение закрылось или не отвечает — подсказку это ронять не должно return null; } } // Системная каретка: координаты приходят относительно окна, которому она принадлежит private static PopupWindowNative.Rect? TryGetSystemCaret(ForegroundInputNative.GuiThreadInfo info) { if (info.hwndCaret == IntPtr.Zero || IsEmpty(info.rcCaret)) { return null; } var topLeft = new PopupWindowNative.Point { X = info.rcCaret.Left, Y = info.rcCaret.Top }; var bottomRight = new PopupWindowNative.Point { X = info.rcCaret.Right, Y = info.rcCaret.Bottom }; if (!ClientToScreen(info.hwndCaret, ref topLeft) || !ClientToScreen(info.hwndCaret, ref bottomRight)) { return null; } return new PopupWindowNative.Rect { Left = topLeft.X, Top = topLeft.Y, Right = bottomRight.X, Bottom = bottomRight.Y, }; } // Каретка через средства доступности: сюда попадают браузеры и Electron private static PopupWindowNative.Rect? TryGetAccessibleCaret(IntPtr hwndFocus) { if (hwndFocus == IntPtr.Zero) { return null; } Guid iid = typeof(IAccessible).GUID; if (AccessibleObjectFromWindow(hwndFocus, OBJID_CARET, ref iid, out IAccessible caret) != 0) { return null; } try { caret.accLocation(out int left, out int top, out int width, out int height, CHILDID_SELF); var rect = new PopupWindowNative.Rect { Left = left, Top = top, Right = left + width, Bottom = top + height, }; return IsEmpty(rect) ? null : rect; } catch (COMException) { // Приложение объявило поддержку, но положение не отдало return null; } finally { Marshal.ReleaseComObject(caret); } } // Когда каретки нет, её прямоугольник приходит нулевой высоты. // Судим только по высоте: нулевые координаты — это обычное начало пустого поля private static bool IsEmpty(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top <= 0; }