diff --git a/CursorLang/Interop/CaretNative.cs b/CursorLang/Interop/CaretNative.cs
new file mode 100644
index 0000000..278c917
--- /dev/null
+++ b/CursorLang/Interop/CaretNative.cs
@@ -0,0 +1,194 @@
+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
+{
+ [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);
+
+ [DllImport("oleacc.dll")]
+ private static extern int AccessibleObjectFromWindow(IntPtr hwnd, uint dwObjectId,
+ ref Guid riid, out IAccessible ppvObject);
+
+ private const uint OBJID_CARET = 0xFFFFFFF8;
+ private const int CHILDID_SELF = 0;
+
+ ///
+ /// Прямоугольник каретки в пикселях экрана или null, если активное
+ /// приложение не сообщает её положение.
+ ///
+ internal static PopupWindowNative.Rect? TryGetCaretRect()
+ {
+ var info = new GuiThreadInfo { cbSize = Marshal.SizeOf() };
+ uint threadId = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);
+ if (!GetGUIThreadInfo(threadId, ref info))
+ {
+ return null;
+ }
+
+ return TryGetSystemCaret(info)
+ ?? TryGetAccessibleCaret(info.hwndFocus)
+ ?? TryGetAutomationCaret();
+ }
+
+ /// Сколько ждём ответа от чужого приложения по 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(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;
+}
diff --git a/CursorLang/Models/PopupPlacement.cs b/CursorLang/Models/PopupPlacement.cs
index 0364a1b..973aff0 100644
--- a/CursorLang/Models/PopupPlacement.cs
+++ b/CursorLang/Models/PopupPlacement.cs
@@ -8,13 +8,20 @@ public enum PopupPlacementMode
/// Рядом с курсором мыши.
AtCursor,
+ ///
+ /// Рядом с кареткой в активном поле ввода. Если приложение не сообщает
+ /// её положение, подсказка показывается у курсора мыши.
+ ///
+ AtCaret,
+
/// В заданной точке монитора с активным окном.
FixedPoint,
}
///
-/// С какой стороны от курсора показывать подсказку.
+/// С какой стороны от курсора или каретки показывать подсказку.
///
+
public enum CursorCorner
{
BottomRight,
diff --git a/CursorLang/Resources/Strings.resx b/CursorLang/Resources/Strings.resx
index 847dbc5..ae4740a 100644
--- a/CursorLang/Resources/Strings.resx
+++ b/CursorLang/Resources/Strings.resx
@@ -76,11 +76,14 @@
Near the cursor
+
+ Near the text caret
+
Fixed point on screen
- Side of the cursor
+ Side
Bottom right
@@ -95,7 +98,7 @@
Top left
- Offset from cursor
+ Offset
Position on screen
diff --git a/CursorLang/Resources/Strings.ru.resx b/CursorLang/Resources/Strings.ru.resx
index fe1e88f..19f561a 100644
--- a/CursorLang/Resources/Strings.ru.resx
+++ b/CursorLang/Resources/Strings.ru.resx
@@ -76,11 +76,14 @@
Рядом с курсором
+
+ Рядом с кареткой ввода
+
В заданной точке экрана
- Сторона от курсора
+ Сторона
Справа снизу
@@ -95,7 +98,7 @@
Слева сверху
- Отступ от курсора
+ Отступ
Позиция на экране
diff --git a/CursorLang/Views/Converters.cs b/CursorLang/Views/Converters.cs
index a382455..f0b5950 100644
--- a/CursorLang/Views/Converters.cs
+++ b/CursorLang/Views/Converters.cs
@@ -6,13 +6,21 @@ using System.Windows.Media;
namespace CursorLang.Views;
///
-/// Показывает элемент, только если значение совпадает с параметром.
-/// Нужен, чтобы настройки курсора и экрана не показывались одновременно.
+/// Показывает элемент, если значение совпадает с одним из перечисленных
+/// в параметре через запятую. Нужен, чтобы настройки точки привязки
+/// и настройки экрана не показывались одновременно.
///
public sealed class EnumToVisibilityConverter : IValueConverter
{
- public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
- value?.ToString() == parameter?.ToString() ? Visibility.Visible : Visibility.Collapsed;
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ string? current = value?.ToString();
+ bool matches = parameter?.ToString()?
+ .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
+ .Any(expected => expected == current) ?? false;
+
+ return matches ? Visibility.Visible : Visibility.Collapsed;
+ }
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
Binding.DoNothing;
diff --git a/CursorLang/Views/LayoutPopupWindow.xaml.cs b/CursorLang/Views/LayoutPopupWindow.xaml.cs
index 9ab30ec..872951f 100644
--- a/CursorLang/Views/LayoutPopupWindow.xaml.cs
+++ b/CursorLang/Views/LayoutPopupWindow.xaml.cs
@@ -22,6 +22,10 @@ public partial class LayoutPopupWindow : Window
DataContext = viewModel;
_settings = settings;
+
+ // Создаём окно заранее, чтобы задать его границы до первого показа:
+ // иначе оно на мгновение появляется в размере по умолчанию
+ new WindowInteropHelper(this).EnsureHandle();
}
///
@@ -70,20 +74,40 @@ public partial class LayoutPopupWindow : Window
return;
}
- if (_settings.PlacementMode == PopupPlacementMode.AtCursor)
- {
- ApplyBoundsAtCursor(handle);
- }
- else
+ if (_settings.PlacementMode == PopupPlacementMode.FixedPoint)
{
ApplyBoundsOnScreen(handle);
}
+ else
+ {
+ ApplyBoundsNearAnchor(handle, GetAnchor());
+ }
}
- private void ApplyBoundsAtCursor(IntPtr handle)
+ // Точка привязки: каретка в поле ввода либо курсор мыши. Курсор — это
+ // прямоугольник нулевого размера, поэтому расчёт углов у них общий
+ private PopupWindowNative.Rect GetAnchor()
{
+ if (_settings.PlacementMode == PopupPlacementMode.AtCaret &&
+ CaretNative.TryGetCaretRect() is { } caret)
+ {
+ return caret;
+ }
+
PopupWindowNative.Point cursor = PopupWindowNative.GetCursorPosition();
- double scale = PopupWindowNative.GetScaleAt(cursor);
+ return new PopupWindowNative.Rect
+ {
+ Left = cursor.X,
+ Top = cursor.Y,
+ Right = cursor.X,
+ Bottom = cursor.Y,
+ };
+ }
+
+ private void ApplyBoundsNearAnchor(IntPtr handle, PopupWindowNative.Rect anchor)
+ {
+ var anchorPoint = new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top };
+ double scale = PopupWindowNative.GetScaleAt(anchorPoint);
int width = ToPixels(_contentSize.Width, scale);
int height = ToPixels(_contentSize.Height, scale);
@@ -92,8 +116,8 @@ public partial class LayoutPopupWindow : Window
bool toRight = _settings.CursorCorner is CursorCorner.BottomRight or CursorCorner.TopRight;
bool toBottom = _settings.CursorCorner is CursorCorner.BottomRight or CursorCorner.BottomLeft;
- int x = toRight ? cursor.X + offset : cursor.X - offset - width;
- int y = toBottom ? cursor.Y + offset : cursor.Y - offset - height;
+ int x = toRight ? anchor.Right + offset : anchor.Left - offset - width;
+ int y = toBottom ? anchor.Bottom + offset : anchor.Top - offset - height;
PopupWindowNative.SetBounds(handle, x, y, width, height);
}
diff --git a/CursorLang/Views/MainWindow.xaml b/CursorLang/Views/MainWindow.xaml
index 2874091..555cf37 100644
--- a/CursorLang/Views/MainWindow.xaml
+++ b/CursorLang/Views/MainWindow.xaml
@@ -4,7 +4,6 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:CursorLang.Views"
- xmlns:models="clr-namespace:CursorLang.Models"
xmlns:vm="clr-namespace:CursorLang.ViewModels"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=vm:SettingsViewModel}"
@@ -82,13 +81,13 @@
SelectedValuePath="Value"
SelectedValue="{Binding Settings.PlacementMode}" />
-
+
+ ConverterParameter='AtCursor,AtCaret'}" />
+ ConverterParameter='AtCursor,AtCaret'}" />
+ ConverterParameter='AtCursor,AtCaret'}" />
+ ConverterParameter='AtCursor,AtCaret'}" />
+ ConverterParameter='AtCursor,AtCaret'}" />
+ ConverterParameter=FixedPoint}" />
+ ConverterParameter=FixedPoint}" />
+ ConverterParameter=FixedPoint}" />
+ ConverterParameter=FixedPoint}" />
+ ConverterParameter=FixedPoint}" />