215 lines
8.9 KiB
C#
215 lines
8.9 KiB
C#
using System.Runtime.InteropServices;
|
|
using System.Windows.Automation;
|
|
using System.Windows.Automation.Text;
|
|
using Accessibility;
|
|
|
|
namespace CursorLang.Interop;
|
|
|
|
/// <summary>
|
|
/// Определяет положение каретки в активном поле ввода — в том числе в чужом приложении.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Единого способа нет: классические Win32-приложения заводят системную каретку,
|
|
/// а Chrome, Electron и прочие рисуют её сами и сообщают положение только через
|
|
/// средства доступности. Поэтому сначала спрашиваем систему, затем — приложение.
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <summary>
|
|
/// Прямоугольник каретки в пикселях экрана или <c>null</c>, если активное
|
|
/// приложение не сообщает её положение.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Отсеивает заведомо неверные координаты.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Часть приложений отдаёт положение каретки в своей системе координат либо
|
|
/// без учёта масштаба экрана, и подсказка уезжает далеко от поля ввода.
|
|
/// Каретка обязана находиться внутри окна ввода — это и проверяем, а перед
|
|
/// отказом пробуем истолковать координаты как немасштабированные.
|
|
/// </remarks>
|
|
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;
|
|
|
|
/// <summary>Сколько ждём ответа от чужого приложения по UI Automation.</summary>
|
|
private static readonly TimeSpan AutomationTimeout = TimeSpan.FromMilliseconds(150);
|
|
|
|
// Браузеры и другие приложения на своих движках рисуют каретку сами и
|
|
// сообщают её положение только через UI Automation. Запрос идёт в чужой
|
|
// процесс, поэтому он самый медленный и стоит последним
|
|
private static PopupWindowNative.Rect? TryGetAutomationCaret()
|
|
{
|
|
// Зависшее приложение не должно подвешивать подсказку вместе с собой:
|
|
// ждём ответ ограниченное время, иначе показываем у курсора
|
|
Task<PopupWindowNative.Rect?> 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;
|
|
}
|