From ba0748920b0d51a63e7bae983074a5a1fb060b57 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Sat, 8 Aug 2026 22:42:31 +0500 Subject: [PATCH] added feature - show tootip near careet --- CursorLang/Interop/CaretNative.cs | 194 +++++++++++++++++++++ CursorLang/Models/PopupPlacement.cs | 9 +- CursorLang/Resources/Strings.resx | 7 +- CursorLang/Resources/Strings.ru.resx | 7 +- CursorLang/Views/Converters.cs | 16 +- CursorLang/Views/LayoutPopupWindow.xaml.cs | 42 ++++- CursorLang/Views/MainWindow.xaml | 23 ++- 7 files changed, 268 insertions(+), 30 deletions(-) create mode 100644 CursorLang/Interop/CaretNative.cs 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}" />