added feature - show tootip near careet

This commit is contained in:
2026-08-08 22:42:31 +05:00
parent 240ffe7687
commit ba0748920b
7 changed files with 268 additions and 30 deletions
+194
View File
@@ -0,0 +1,194 @@
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
{
[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;
/// <summary>
/// Прямоугольник каретки в пикселях экрана или <c>null</c>, если активное
/// приложение не сообщает её положение.
/// </summary>
internal static PopupWindowNative.Rect? TryGetCaretRect()
{
var info = new GuiThreadInfo { cbSize = Marshal.SizeOf<GuiThreadInfo>() };
uint threadId = GetWindowThreadProcessId(GetForegroundWindow(), IntPtr.Zero);
if (!GetGUIThreadInfo(threadId, ref info))
{
return null;
}
return TryGetSystemCaret(info)
?? TryGetAccessibleCaret(info.hwndFocus)
?? TryGetAutomationCaret();
}
/// <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(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;
}
+8 -1
View File
@@ -8,13 +8,20 @@ public enum PopupPlacementMode
/// <summary>Рядом с курсором мыши.</summary>
AtCursor,
/// <summary>
/// Рядом с кареткой в активном поле ввода. Если приложение не сообщает
/// её положение, подсказка показывается у курсора мыши.
/// </summary>
AtCaret,
/// <summary>В заданной точке монитора с активным окном.</summary>
FixedPoint,
}
/// <summary>
/// С какой стороны от курсора показывать подсказку.
/// С какой стороны от курсора или каретки показывать подсказку.
/// </summary>
public enum CursorCorner
{
BottomRight,
+5 -2
View File
@@ -76,11 +76,14 @@
<data name="PopupPlacementMode_AtCursor" xml:space="preserve">
<value>Near the cursor</value>
</data>
<data name="PopupPlacementMode_AtCaret" xml:space="preserve">
<value>Near the text caret</value>
</data>
<data name="PopupPlacementMode_FixedPoint" xml:space="preserve">
<value>Fixed point on screen</value>
</data>
<data name="CursorCornerLabel" xml:space="preserve">
<value>Side of the cursor</value>
<value>Side</value>
</data>
<data name="CursorCorner_BottomRight" xml:space="preserve">
<value>Bottom right</value>
@@ -95,7 +98,7 @@
<value>Top left</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve">
<value>Offset from cursor</value>
<value>Offset</value>
</data>
<data name="ScreenPositionLabel" xml:space="preserve">
<value>Position on screen</value>
+5 -2
View File
@@ -76,11 +76,14 @@
<data name="PopupPlacementMode_AtCursor" xml:space="preserve">
<value>Рядом с курсором</value>
</data>
<data name="PopupPlacementMode_AtCaret" xml:space="preserve">
<value>Рядом с кареткой ввода</value>
</data>
<data name="PopupPlacementMode_FixedPoint" xml:space="preserve">
<value>В заданной точке экрана</value>
</data>
<data name="CursorCornerLabel" xml:space="preserve">
<value>Сторона от курсора</value>
<value>Сторона</value>
</data>
<data name="CursorCorner_BottomRight" xml:space="preserve">
<value>Справа снизу</value>
@@ -95,7 +98,7 @@
<value>Слева сверху</value>
</data>
<data name="CursorOffsetLabel" xml:space="preserve">
<value>Отступ от курсора</value>
<value>Отступ</value>
</data>
<data name="ScreenPositionLabel" xml:space="preserve">
<value>Позиция на экране</value>
+12 -4
View File
@@ -6,13 +6,21 @@ using System.Windows.Media;
namespace CursorLang.Views;
/// <summary>
/// Показывает элемент, только если значение совпадает с параметром.
/// Нужен, чтобы настройки курсора и экрана не показывались одновременно.
/// Показывает элемент, если значение совпадает с одним из перечисленных
/// в параметре через запятую. Нужен, чтобы настройки точки привязки
/// и настройки экрана не показывались одновременно.
/// </summary>
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;
+33 -9
View File
@@ -22,6 +22,10 @@ public partial class LayoutPopupWindow : Window
DataContext = viewModel;
_settings = settings;
// Создаём окно заранее, чтобы задать его границы до первого показа:
// иначе оно на мгновение появляется в размере по умолчанию
new WindowInteropHelper(this).EnsureHandle();
}
/// <summary>
@@ -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);
}
+11 -12
View File
@@ -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}" />
<!-- Режим «у курсора» -->
<!-- Привязка к точке: курсор или каретка -->
<TextBlock Grid.Row="1" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorCornerLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
ConverterParameter='AtCursor,AtCaret'}" />
<ComboBox Grid.Row="1" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding CursorCorners}"
DisplayMemberPath="Display"
@@ -96,26 +95,26 @@
SelectedValue="{Binding Settings.CursorCorner}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
ConverterParameter='AtCursor,AtCaret'}" />
<TextBlock Grid.Row="2" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[CursorOffsetLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
ConverterParameter='AtCursor,AtCaret'}" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="80" TickFrequency="1"
Value="{Binding Settings.CursorOffset}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
ConverterParameter='AtCursor,AtCaret'}" />
<TextBlock Grid.Row="2" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.CursorOffset, StringFormat={}{0:F0}}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.AtCursor}}" />
ConverterParameter='AtCursor,AtCaret'}" />
<!-- Режим «фиксированная точка» -->
<TextBlock Grid.Row="3" Margin="0,12,12,0"
@@ -123,7 +122,7 @@
Text="{Binding Localization[ScreenPositionLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
ConverterParameter=FixedPoint}" />
<ComboBox Grid.Row="3" Grid.Column="1" Grid.ColumnSpan="2" Margin="0,12,0,0"
ItemsSource="{Binding ScreenPositions}"
DisplayMemberPath="Display"
@@ -131,26 +130,26 @@
SelectedValue="{Binding Settings.ScreenPosition}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
ConverterParameter=FixedPoint}" />
<TextBlock Grid.Row="4" Margin="0,12,12,0"
Style="{StaticResource FieldLabel}"
Text="{Binding Localization[ScreenMarginLabel]}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
ConverterParameter=FixedPoint}" />
<Slider Grid.Row="4" Grid.Column="1" Margin="0,12,0,0"
Minimum="0" Maximum="200" TickFrequency="1"
Value="{Binding Settings.ScreenMargin}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
ConverterParameter=FixedPoint}" />
<TextBlock Grid.Row="4" Grid.Column="2" Margin="12,12,0,0"
Style="{StaticResource FieldValue}"
Text="{Binding Settings.ScreenMargin, StringFormat={}{0:F0}}"
Visibility="{Binding Settings.PlacementMode,
Converter={StaticResource EnumToVisibility},
ConverterParameter={x:Static models:PopupPlacementMode.FixedPoint}}" />
ConverterParameter=FixedPoint}" />
</Grid>
</GroupBox>