238 lines
8.8 KiB
C#
238 lines
8.8 KiB
C#
using System.Runtime.InteropServices;
|
|
#if CARET_UI_AUTOMATION
|
|
using System.Windows.Automation;
|
|
using System.Windows.Automation.Text;
|
|
#endif
|
|
using Accessibility;
|
|
|
|
namespace CursorLang.Core.Interop;
|
|
|
|
/// <summary>
|
|
/// Locates the caret in the active input field — including one in another application.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// There is no single way to do it: classic Win32 applications create a system caret,
|
|
/// while Chrome, Electron and others draw it themselves and report its position only
|
|
/// through accessibility interfaces. So we ask the system first, then the application.
|
|
///
|
|
/// The UI Automation step is behind <c>CARET_UI_AUTOMATION</c>: it is the one part of
|
|
/// the background process that reaches into the WPF half of the desktop runtime —
|
|
/// TextPatternRange hands its rectangles back as System.Windows.Rect, which lives in
|
|
/// WindowsBase — and it was measured at +3.9 MB private. It is also the last of the
|
|
/// three steps and rarely reached. See the switch in CursorLang.Core.csproj.
|
|
/// </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>
|
|
/// The caret rectangle in screen pixels, or <c>null</c> when the active
|
|
/// application does not report its position.
|
|
/// </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>
|
|
/// Filters out obviously wrong coordinates.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Some applications report the caret position in their own coordinate system or
|
|
/// without accounting for display scaling, and the popup ends up far from the input
|
|
/// field. The caret must be inside the input window — that is what we check, and
|
|
/// before giving up we try to read the coordinates as unscaled ones.
|
|
/// </remarks>
|
|
private static PopupWindowNative.Rect? Validate(PopupWindowNative.Rect caret, IntPtr hwndFocus)
|
|
{
|
|
if (hwndFocus == IntPtr.Zero || !GetWindowRect(hwndFocus, out PopupWindowNative.Rect window))
|
|
{
|
|
return caret;
|
|
}
|
|
|
|
return Validate(caret, window, GetDpiForWindow(hwndFocus) / 96.0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same check over plain numbers: the input window bounds and the scale of
|
|
/// its monitor are already known.
|
|
/// </summary>
|
|
internal static PopupWindowNative.Rect? Validate(
|
|
PopupWindowNative.Rect caret, PopupWindowNative.Rect window, double scale)
|
|
{
|
|
if (IsInside(caret, window))
|
|
{
|
|
return caret;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
internal 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;
|
|
|
|
#if CARET_UI_AUTOMATION
|
|
/// <summary>How long we wait for another application to answer over UI Automation.</summary>
|
|
private static readonly TimeSpan AutomationTimeout = TimeSpan.FromMilliseconds(150);
|
|
|
|
// Browsers and other applications with their own rendering engines draw the caret
|
|
// themselves and report its position only through UI Automation. The request goes
|
|
// into another process, so it is the slowest one and comes last
|
|
private static PopupWindowNative.Rect? TryGetAutomationCaret()
|
|
{
|
|
// A hung application must not hang the popup along with it: we wait for the
|
|
// answer for a limited time, otherwise the popup goes to the fixed point
|
|
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;
|
|
}
|
|
|
|
// The caret has an empty selection and therefore no rectangle,
|
|
// so we expand it to the nearest character
|
|
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)
|
|
{
|
|
// The application closed or stopped responding — that must not take the popup down
|
|
return null;
|
|
}
|
|
}
|
|
#else
|
|
// Built without UI Automation: Chromium and Electron keep the system caret and MSAA
|
|
// steps above, and where those stay silent the popup falls back to the fixed point
|
|
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
|
|
#endif
|
|
|
|
// The system caret: its coordinates come relative to the window that owns it
|
|
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,
|
|
};
|
|
}
|
|
|
|
// The caret through accessibility interfaces: this is where browsers and Electron land
|
|
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)
|
|
{
|
|
// The application declared support but did not report the position
|
|
return null;
|
|
}
|
|
finally
|
|
{
|
|
Marshal.ReleaseComObject(caret);
|
|
}
|
|
}
|
|
|
|
// When there is no caret, its rectangle comes back with zero height.
|
|
// We judge by height alone: zero coordinates are a normal start of an empty field
|
|
internal static bool IsEmpty(PopupWindowNative.Rect rect) => rect.Bottom - rect.Top <= 0;
|
|
}
|