diff --git a/CursorLang.Agent/Windows/NativePopupWindow.cs b/CursorLang.Agent/Windows/NativePopupWindow.cs index 7018f43..6572f7b 100644 --- a/CursorLang.Agent/Windows/NativePopupWindow.cs +++ b/CursorLang.Agent/Windows/NativePopupWindow.cs @@ -68,42 +68,42 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow return; } - bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint; + PopupWindowNative.Rect? anchor = TryGetAnchor(); + PopupModeSettings mode = ModeFor(anchor); + PopupWindowNative.Rect work = default; - PopupWindowNative.Rect anchor = default; 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 { - anchor = GetAnchor(); - scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top }); + (work, scale) = PopupWindowNative.GetActiveMonitorWorkArea(); } - EnsureFont(scale); + EnsureFont(mode, scale); Size measured = MeasureText(text); int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale)); int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale)); - PopupWindowNative.Point position = atFixedPoint - ? PopupLayout.OnScreen( - work, - _settings.FixedPoint.Position, - PopupLayout.ToPixels(_settings.FixedPoint.Offset, scale), + PopupWindowNative.Point position = anchor is { } near + ? PopupLayout.NearAnchor( + near, + SideForMode(), + PopupLayout.ToPixels(mode.Offset, scale), width, height) - : PopupLayout.NearAnchor( - anchor, - SideForMode(), - PopupLayout.ToPixels(_settings.Current.Offset, scale), + : PopupLayout.OnScreen( + work, + _settings.FixedPoint.Position, + PopupLayout.ToPixels(mode.Offset, scale), width, height); - if (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale))) + if (!Draw(mode, text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale))) { 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 /// would have to be rebuilt on every change of size, colour or scale anyway. /// - 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); if (screen == IntPtr.Zero) @@ -166,8 +167,8 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow GdiNative.SelectObject(memory, surface); - Fill(bits, width, height); - DrawText(memory, text, width, height); + Fill(mode, bits, width, height); + DrawText(mode, memory, text, width, height); // 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 @@ -178,7 +179,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow RoundTheCorners(bits, width, height, radius); 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); } @@ -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 // 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) { @@ -227,7 +229,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font); 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.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); } - // The anchor point: the caret in the input field or the mouse cursor. The cursor - // is a rectangle of zero size, so the corner computation is shared by both - private PopupWindowNative.Rect GetAnchor() + /// + /// What the popup is placed next to, or null when there is nothing: the fixed + /// point mode, and the caret mode where the application reports no caret. + /// + /// + /// The cursor is a rectangle of zero size, so the corner arithmetic is shared by it + /// and the caret. + /// + private PopupWindowNative.Rect? TryGetAnchor() => _settings.PlacementMode switch { - if (_settings.PlacementMode == PopupPlacementMode.AtCaret && - CaretNative.TryGetCaretRect() is { } caret) - { - return caret; - } + PopupPlacementMode.FixedPoint => null, + PopupPlacementMode.AtCaret => CaretNative.TryGetCaretRect(), + _ => PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()), + }; - return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition()); - } + /// + /// 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. + /// + /// + /// 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. + /// + 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 // 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 // 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 - private void EnsureFont(double scale) + private void EnsureFont(PopupModeSettings mode, double scale) { 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) { return; @@ -384,7 +408,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow ReleaseFont(); - _fontSize = _settings.Current.FontSize; + _fontSize = mode.FontSize; _fontScale = scale; _font = GdiNative.CreateFont(_fontSize, scale); } diff --git a/CursorLang.Core/Interop/CaretNative.cs b/CursorLang.Core/Interop/CaretNative.cs index d86019b..5a35409 100644 --- a/CursorLang.Core/Interop/CaretNative.cs +++ b/CursorLang.Core/Interop/CaretNative.cs @@ -113,7 +113,7 @@ internal static class CaretNative 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 we show the popup at the cursor + // answer for a limited time, otherwise the popup goes to the fixed point Task query = Task.Run(QueryAutomationCaret); return query.Wait(AutomationTimeout) ? query.Result : null; } @@ -165,7 +165,7 @@ internal static class CaretNative } #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 cursor + // steps above, and where those stay silent the popup falls back to the fixed point private static PopupWindowNative.Rect? TryGetAutomationCaret() => null; #endif diff --git a/CursorLang.Core/Models/PopupPlacement.cs b/CursorLang.Core/Models/PopupPlacement.cs index d58a961..472cea4 100644 --- a/CursorLang.Core/Models/PopupPlacement.cs +++ b/CursorLang.Core/Models/PopupPlacement.cs @@ -10,7 +10,8 @@ public enum PopupPlacementMode /// /// 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 — at the + /// place and with the look that mode is set up with. /// AtCaret, diff --git a/README.RU.md b/README.RU.md index 7db9213..4284253 100644 --- a/README.RU.md +++ b/README.RU.md @@ -169,6 +169,15 @@ MSIX всегда выполняются в контексте вошедшег на месте, а не исчезает из раздела — появляющаяся и исчезающая строка сдвигала бы всё, что ниже, при каждой смене места. +Положение каретки сообщает не всякое приложение, и тогда рядом с курсором ввода — +нигде. Подсказка уходит на фиксированную точку: место, отступ и оформление берутся +оттуда, из режима каретки не берётся ничего. Курсор мыши был бы запасным вариантом +поближе — и неверным: он там, где его оставили, сбоку или на другом мониторе, тогда +как смотрят на каретку. Фиксированная точка всегда на одном и том же месте, и потому +известно, куда смотреть, — а вместе с местом должно приходить и оформление: подсказка, +настроенная мелкой и незаметной для середины текста, в углу монитора останется +незамеченной. + К какому режиму относится оформление, объясняет всплывающая подсказка рядом с самим режимом, а не строка текста в разделе: ползунки ниже показывают другие числа после переключения режима, и это вопрос, который задают один раз. diff --git a/README.md b/README.md index d71e85c..614d4ba 100644 --- a/README.md +++ b/README.md @@ -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 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 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.