modified caret mode (#11)

Reviewed-on: #11
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #11.
This commit is contained in:
2026-08-14 23:07:20 +00:00
committed by alex
parent b2c7b31eb5
commit 9c6ec489e1
5 changed files with 84 additions and 41 deletions
+62 -38
View File
@@ -68,42 +68,42 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
return; return;
} }
bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint; PopupWindowNative.Rect? anchor = TryGetAnchor();
PopupModeSettings mode = ModeFor(anchor);
PopupWindowNative.Rect work = default; PopupWindowNative.Rect work = default;
PopupWindowNative.Rect anchor = default;
double scale; double scale;
if (atFixedPoint) if (anchor is { } at)
{ {
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea(); scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = at.Left, Y = at.Top });
} }
else else
{ {
anchor = GetAnchor(); (work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top });
} }
EnsureFont(scale); EnsureFont(mode, scale);
Size measured = MeasureText(text); Size measured = MeasureText(text);
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale)); int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale)); int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
PopupWindowNative.Point position = atFixedPoint PopupWindowNative.Point position = anchor is { } near
? PopupLayout.OnScreen( ? PopupLayout.NearAnchor(
work, near,
_settings.FixedPoint.Position, SideForMode(),
PopupLayout.ToPixels(_settings.FixedPoint.Offset, scale), PopupLayout.ToPixels(mode.Offset, scale),
width, width,
height) height)
: PopupLayout.NearAnchor( : PopupLayout.OnScreen(
anchor, work,
SideForMode(), _settings.FixedPoint.Position,
PopupLayout.ToPixels(_settings.Current.Offset, scale), PopupLayout.ToPixels(mode.Offset, scale),
width, width,
height); height);
if (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale))) if (!Draw(mode, text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
{ {
return; return;
} }
@@ -139,7 +139,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
/// kilobytes for the length of one call, the popup is shown rarely, and a cached one /// kilobytes for the length of one call, the popup is shown rarely, and a cached one
/// would have to be rebuilt on every change of size, colour or scale anyway. /// would have to be rebuilt on every change of size, colour or scale anyway.
/// </remarks> /// </remarks>
private bool Draw(string text, PopupWindowNative.Point at, int width, int height, int radius) private bool Draw(
PopupModeSettings mode, string text, PopupWindowNative.Point at, int width, int height, int radius)
{ {
IntPtr screen = GdiNative.GetDC(IntPtr.Zero); IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
if (screen == IntPtr.Zero) if (screen == IntPtr.Zero)
@@ -166,8 +167,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
GdiNative.SelectObject(memory, surface); GdiNative.SelectObject(memory, surface);
Fill(bits, width, height); Fill(mode, bits, width, height);
DrawText(memory, text, width, height); DrawText(mode, memory, text, width, height);
// GDI writes nothing into the alpha channel, so the letters it just drew are // GDI writes nothing into the alpha channel, so the letters it just drew are
// sitting at zero alpha and would come out invisible. The inside of the // sitting at zero alpha and would come out invisible. The inside of the
@@ -178,7 +179,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
RoundTheCorners(bits, width, height, radius); RoundTheCorners(bits, width, height, radius);
var size = new WindowNative.Size { Width = width, Height = height }; var size = new WindowNative.Size { Width = width, Height = height };
var alpha = (byte)Math.Clamp(Math.Round(_settings.Current.Opacity * 255), 0, 255); var alpha = (byte)Math.Clamp(Math.Round(mode.Opacity * 255), 0, 255);
return WindowNative.SetContent(Handle, at, size, memory, alpha); return WindowNative.SetContent(Handle, at, size, memory, alpha);
} }
@@ -200,9 +201,9 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
} }
} }
private void Fill(IntPtr bits, int width, int height) private static void Fill(PopupModeSettings mode, IntPtr bits, int width, int height)
{ {
Color background = _settings.Current.BackgroundColor; Color background = mode.BackgroundColor;
// Straight into the bitmap rather than through a brush: the pixels have to be // Straight into the bitmap rather than through a brush: the pixels have to be
// written anyway to carry an alpha channel GDI would not touch // written anyway to carry an alpha channel GDI would not touch
@@ -216,7 +217,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
} }
} }
private void DrawText(IntPtr deviceContext, string text, int width, int height) private void DrawText(
PopupModeSettings mode, IntPtr deviceContext, string text, int width, int height)
{ {
if (_font == IntPtr.Zero || text.Length == 0) if (_font == IntPtr.Zero || text.Length == 0)
{ {
@@ -227,7 +229,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font); IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT); GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(_settings.Current.ForegroundColor)); GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(mode.ForegroundColor));
GdiNative.DrawText(deviceContext, text, text.Length, ref bounds, GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER | GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
@@ -329,18 +331,40 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
return (int)((alpha << 24) | (red << 16) | (green << 8) | blue); return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
} }
// The anchor point: the caret in the input field or the mouse cursor. The cursor /// <summary>
// is a rectangle of zero size, so the corner computation is shared by both /// What the popup is placed next to, or <c>null</c> when there is nothing: the fixed
private PopupWindowNative.Rect GetAnchor() /// point mode, and the caret mode where the application reports no caret.
/// </summary>
/// <remarks>
/// The cursor is a rectangle of zero size, so the corner arithmetic is shared by it
/// and the caret.
/// </remarks>
private PopupWindowNative.Rect? TryGetAnchor() => _settings.PlacementMode switch
{ {
if (_settings.PlacementMode == PopupPlacementMode.AtCaret && PopupPlacementMode.FixedPoint => null,
CaretNative.TryGetCaretRect() is { } caret) PopupPlacementMode.AtCaret => CaretNative.TryGetCaretRect(),
{ _ => PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()),
return caret; };
}
return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()); /// <summary>
} /// The settings the popup is shown with: those of the mode chosen, or those of the
/// fixed point when there is no anchor to stand next to.
/// </summary>
/// <remarks>
/// The caret mode falls back to the fixed point rather than to the mouse cursor:
/// the cursor is wherever it was last left — off to a side, on another monitor, or
/// over the very text being typed — and a popup that lands there while the eyes are
/// on the caret is one that is looked for and not found. The fixed point is always
/// in the same place, so it is known where to look.
///
/// The look comes from the fixed point mode too, not just the place. The two are set
/// up together for a reason: the popup by the caret is small and quiet because it
/// sits inside a text being read, while the one in the corner of the monitor is
/// looked for on purpose and is set larger. Keeping the caret look at the corner
/// would put a popup meant to go unnoticed where nothing else draws the eye.
/// </remarks>
private PopupModeSettings ModeFor(PopupWindowNative.Rect? anchor) =>
anchor is null ? _settings.FixedPoint : _settings.Current;
// The caret has two sides to choose from and the cursor has six, so each mode names // The caret has two sides to choose from and the cursor has six, so each mode names
// its own side in its own terms // its own side in its own terms
@@ -373,10 +397,10 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
// The font is rebuilt only when the size in the settings or the monitor scale // The font is rebuilt only when the size in the settings or the monitor scale
// changes: it is the one expensive thing a show does. A switch of the placement // changes: it is the one expensive thing a show does. A switch of the placement
// mode counts as a change of the size, since the size belongs to the mode // mode counts as a change of the size, since the size belongs to the mode
private void EnsureFont(double scale) private void EnsureFont(PopupModeSettings mode, double scale)
{ {
if (_font != IntPtr.Zero && if (_font != IntPtr.Zero &&
Math.Abs(_fontSize - _settings.Current.FontSize) < 0.01 && Math.Abs(_fontSize - mode.FontSize) < 0.01 &&
Math.Abs(_fontScale - scale) < 0.01) Math.Abs(_fontScale - scale) < 0.01)
{ {
return; return;
@@ -384,7 +408,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
ReleaseFont(); ReleaseFont();
_fontSize = _settings.Current.FontSize; _fontSize = mode.FontSize;
_fontScale = scale; _fontScale = scale;
_font = GdiNative.CreateFont(_fontSize, scale); _font = GdiNative.CreateFont(_fontSize, scale);
} }
+2 -2
View File
@@ -113,7 +113,7 @@ internal static class CaretNative
private static PopupWindowNative.Rect? TryGetAutomationCaret() private static PopupWindowNative.Rect? TryGetAutomationCaret()
{ {
// A hung application must not hang the popup along with it: we wait for the // A hung application must not hang the popup along with it: we wait for the
// answer for a limited time, otherwise we show the popup at the cursor // answer for a limited time, otherwise the popup goes to the fixed point
Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret); Task<PopupWindowNative.Rect?> query = Task.Run(QueryAutomationCaret);
return query.Wait(AutomationTimeout) ? query.Result : null; return query.Wait(AutomationTimeout) ? query.Result : null;
} }
@@ -165,7 +165,7 @@ internal static class CaretNative
} }
#else #else
// Built without UI Automation: Chromium and Electron keep the system caret and MSAA // 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 cursor // steps above, and where those stay silent the popup falls back to the fixed point
private static PopupWindowNative.Rect? TryGetAutomationCaret() => null; private static PopupWindowNative.Rect? TryGetAutomationCaret() => null;
#endif #endif
+2 -1
View File
@@ -10,7 +10,8 @@ public enum PopupPlacementMode
/// <summary> /// <summary>
/// Next to the caret in the active input field. When the application does not /// Next to the caret in the active input field. When the application does not
/// report its position, the popup is shown at the mouse cursor. /// report its position, the popup is shown as in <see cref="FixedPoint"/> — at the
/// place and with the look that mode is set up with.
/// </summary> /// </summary>
AtCaret, AtCaret,
+9
View File
@@ -169,6 +169,15 @@ MSIX всегда выполняются в контексте вошедшег
на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы
всё, что ниже, при каждой смене места. всё, что ниже, при каждой смене места.
Положение каретки сообщает не всякое приложение, и тогда рядом с курсором ввода —
нигде. Подсказка уходит на фиксированную точку: место, отступ и оформление берутся
оттуда, из режима каретки не берётся ничего. Курсор мыши был бы запасным вариантом
поближе — и неверным: он там, где его оставили, сбоку или на другом мониторе, тогда
как смотрят на каретку. Фиксированная точка всегда на одном и том же месте, и потому
известно, куда смотреть, — а вместе с местом должно приходить и оформление: подсказка,
настроенная мелкой и незаметной для середины текста, в углу монитора останется
незамеченной.
К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим
режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после
переключения режима, и это вопрос, который задают один раз. переключения режима, и это вопрос, который задают один раз.
+9
View File
@@ -163,6 +163,15 @@ zero and the settings window shows it greyed out. It stays in place rather than
leaving the section — a row that comes and goes would move everything below it on leaving the section — a row that comes and goes would move everything below it on
every switch of the place. every switch of the place.
Not every application reports where its caret is, and next to the caret is then
nowhere. The popup goes to the fixed point instead — the place, the offset and the
look all from that mode, none of them from the caret one. The mouse cursor would be
the nearer fallback and is the wrong one: it is wherever it was last left, off to a
side or on another monitor, while the eyes are on the caret. The fixed point is
always in the same place, so it is known where to look — and the look must come with
it, since a popup set small and quiet for the middle of a text goes unnoticed in the
corner of a monitor.
Which mode the look belongs to is explained in a tooltip next to the mode itself Which mode the look belongs to is explained in a tooltip next to the mode itself
rather than by a line of text in the section: the sliders below show other numbers rather than by a line of text in the section: the sliders below show other numbers
after a switch of the mode, and that is a question asked once. after a switch of the mode, and that is a question asked once.