322 lines
10 KiB
C#
322 lines
10 KiB
C#
using System.Drawing;
|
|
using CursorLang.Agent.Interop;
|
|
using CursorLang.Agent.Services;
|
|
using CursorLang.Core.Interop;
|
|
using CursorLang.Core.Models;
|
|
using CursorLang.Core.Services;
|
|
|
|
namespace CursorLang.Agent.Windows;
|
|
|
|
/// <summary>
|
|
/// The popup with the short name of the layout, drawn by Win32 alone.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A like-for-like replacement of the WPF popup this once was: a rounded rectangle of
|
|
/// radius 4 with 10×4 padding, the fill and the text colour from the settings, the whole
|
|
/// thing at the opacity from the settings, the name in Segoe UI SemiBold at the size
|
|
/// from the settings. Measured against it side by side, the two agreed to the pixel in
|
|
/// position and width.
|
|
///
|
|
/// The opacity comes from <c>SetLayeredWindowAttributes</c> and the rounded corners from
|
|
/// a window region, which is the cheaper of the two ways of doing it: the text keeps
|
|
/// ClearType, and no GDI+ or Direct2D is needed. The price is that a region is a binary
|
|
/// mask — the corners are cut without antialiasing. At radius 4 that is a stepped arc of
|
|
/// some fourteen pixels per corner, visible at eight times magnification and not at
|
|
/// natural size, which is why this way was kept.
|
|
///
|
|
/// Responsible only for showing the popup, its size and its place on screen: when to
|
|
/// take it down is decided by <see cref="LayoutPopupService"/>.
|
|
/// </remarks>
|
|
internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
|
|
{
|
|
private const string ClassName = "CursorLang.Agent.Popup";
|
|
|
|
// The numbers of the XAML: Border CornerRadius="4" Padding="10,4", all in WPF units
|
|
private const double CornerRadius = 4;
|
|
private const double PaddingX = 10;
|
|
private const double PaddingY = 4;
|
|
|
|
private readonly AppSettings _settings;
|
|
|
|
private string _text = string.Empty;
|
|
private IntPtr _font;
|
|
private IntPtr _background;
|
|
|
|
private double _fontSize;
|
|
private double _fontScale;
|
|
private uint _backgroundColorRef;
|
|
private byte? _alpha;
|
|
private Size _regionSize;
|
|
private int _regionRadius = -1;
|
|
|
|
internal NativePopupWindow(AppSettings settings)
|
|
: base(
|
|
ClassName,
|
|
"CursorLang popup",
|
|
WindowNative.WS_POPUP,
|
|
WindowNative.WS_EX_LAYERED | WindowNative.WS_EX_TOOLWINDOW |
|
|
WindowNative.WS_EX_NOACTIVATE | WindowNative.WS_EX_TRANSPARENT |
|
|
WindowNative.WS_EX_TOPMOST)
|
|
{
|
|
_settings = settings;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shows the popup with the given text at the place set by the settings.
|
|
/// </summary>
|
|
public void ShowPopup(string text)
|
|
{
|
|
if (Handle == IntPtr.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_text = text;
|
|
|
|
bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint;
|
|
PopupWindowNative.Rect work = default;
|
|
PopupWindowNative.Rect anchor = default;
|
|
double scale;
|
|
|
|
if (atFixedPoint)
|
|
{
|
|
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
|
}
|
|
else
|
|
{
|
|
anchor = GetAnchor();
|
|
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = anchor.Left, Y = anchor.Top });
|
|
}
|
|
|
|
EnsureFont(scale);
|
|
|
|
Size measured = MeasureText();
|
|
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.ScreenPosition, PopupLayout.ToPixels(_settings.ScreenMargin, scale), width, height)
|
|
: PopupLayout.NearAnchor(anchor, AnchorSideForMode(), OffsetForMode(scale), width, height);
|
|
|
|
PopupWindowNative.SetBounds(Handle, position.X, position.Y, width, height);
|
|
|
|
ApplyRegion(width, height, PopupLayout.ToPixels(CornerRadius, scale));
|
|
ApplyOpacity();
|
|
|
|
if (!WindowNative.IsWindowVisible(Handle))
|
|
{
|
|
WindowNative.ShowWindow(Handle, WindowNative.SW_SHOWNOACTIVATE);
|
|
}
|
|
|
|
// The repaint is forced rather than left to the queue: a layout switch is
|
|
// followed at once by the user looking at the popup, and a WM_PAINT waiting its
|
|
// turn behind a slow message is exactly how stale text gets on screen
|
|
WindowNative.InvalidateRect(Handle, IntPtr.Zero, false);
|
|
WindowNative.UpdateWindow(Handle);
|
|
}
|
|
|
|
public void Hide()
|
|
{
|
|
if (Handle != IntPtr.Zero)
|
|
{
|
|
WindowNative.ShowWindow(Handle, WindowNative.SW_HIDE);
|
|
}
|
|
}
|
|
|
|
/// <summary>Destroys the window. The agent only does this on the way out.</summary>
|
|
public void Close() => Dispose();
|
|
|
|
public override void Dispose()
|
|
{
|
|
ReleaseFont();
|
|
|
|
if (_background != IntPtr.Zero)
|
|
{
|
|
GdiNative.DeleteObject(_background);
|
|
_background = IntPtr.Zero;
|
|
}
|
|
|
|
base.Dispose();
|
|
}
|
|
|
|
protected override bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
|
|
{
|
|
result = IntPtr.Zero;
|
|
|
|
if (message != WindowNative.WM_PAINT)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
Paint();
|
|
return true;
|
|
}
|
|
|
|
// 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()
|
|
{
|
|
if (_settings.PlacementMode == PopupPlacementMode.AtCaret &&
|
|
CaretNative.TryGetCaretRect() is { } caret)
|
|
{
|
|
return caret;
|
|
}
|
|
|
|
return PopupLayout.AsAnchor(PopupWindowNative.GetCursorPosition());
|
|
}
|
|
|
|
// Every anchor mode has a side and an offset of its own
|
|
private AnchorSide AnchorSideForMode() =>
|
|
_settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretSide : _settings.CursorSide;
|
|
|
|
private int OffsetForMode(double scale) => PopupLayout.ToPixels(
|
|
_settings.PlacementMode == PopupPlacementMode.AtCaret ? _settings.CaretOffset : _settings.CursorOffset,
|
|
scale);
|
|
|
|
private void Paint()
|
|
{
|
|
IntPtr deviceContext = GdiNative.BeginPaint(Handle, out GdiNative.PaintStruct paint);
|
|
if (deviceContext == IntPtr.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
WindowNative.GetClientRect(Handle, out PopupWindowNative.Rect client);
|
|
|
|
GdiNative.FillRect(deviceContext, ref client, EnsureBackground());
|
|
|
|
if (_font == IntPtr.Zero || _text.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
|
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
|
|
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(_settings.ForegroundColor));
|
|
|
|
GdiNative.DrawText(deviceContext, _text, _text.Length, ref client,
|
|
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
|
|
GdiNative.DT_NOPREFIX | GdiNative.DT_NOCLIP);
|
|
|
|
GdiNative.SelectObject(deviceContext, previousFont);
|
|
}
|
|
finally
|
|
{
|
|
GdiNative.EndPaint(Handle, ref paint);
|
|
}
|
|
}
|
|
|
|
private Size MeasureText()
|
|
{
|
|
IntPtr deviceContext = GdiNative.GetDC(Handle);
|
|
if (deviceContext == IntPtr.Zero)
|
|
{
|
|
return Size.Empty;
|
|
}
|
|
|
|
try
|
|
{
|
|
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
|
Size size = GdiNative.MeasureText(deviceContext, _text);
|
|
GdiNative.SelectObject(deviceContext, previousFont);
|
|
|
|
return size;
|
|
}
|
|
finally
|
|
{
|
|
GdiNative.ReleaseDC(Handle, deviceContext);
|
|
}
|
|
}
|
|
|
|
// The font is rebuilt only when the size in the settings or the monitor changes:
|
|
// creating one costs a trip to the font mapper, and the popup is shown often
|
|
private void EnsureFont(double scale)
|
|
{
|
|
if (_font != IntPtr.Zero &&
|
|
Math.Abs(_fontSize - _settings.FontSize) < 0.001 &&
|
|
Math.Abs(_fontScale - scale) < 0.001)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ReleaseFont();
|
|
|
|
_fontSize = _settings.FontSize;
|
|
_fontScale = scale;
|
|
_font = GdiNative.CreateFont(_fontSize, scale);
|
|
}
|
|
|
|
private void ReleaseFont()
|
|
{
|
|
if (_font == IntPtr.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
GdiNative.DeleteObject(_font);
|
|
_font = IntPtr.Zero;
|
|
}
|
|
|
|
private IntPtr EnsureBackground()
|
|
{
|
|
uint colorRef = GdiNative.ToColorRef(_settings.BackgroundColor);
|
|
if (_background != IntPtr.Zero && _backgroundColorRef == colorRef)
|
|
{
|
|
return _background;
|
|
}
|
|
|
|
if (_background != IntPtr.Zero)
|
|
{
|
|
GdiNative.DeleteObject(_background);
|
|
}
|
|
|
|
_backgroundColorRef = colorRef;
|
|
_background = GdiNative.CreateSolidBrush(colorRef);
|
|
|
|
return _background;
|
|
}
|
|
|
|
// A region is in window coordinates, so a resize invalidates it. Reapplying it
|
|
// every show would do no harm, but the system frees the old region each time and
|
|
// the popup is shown far more often than it changes size
|
|
private void ApplyRegion(int width, int height, int radius)
|
|
{
|
|
if (_regionSize.Width == width && _regionSize.Height == height && _regionRadius == radius)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// The right and bottom edges of CreateRoundRectRgn are exclusive, and the
|
|
// ellipse the corners are cut with is twice the radius across
|
|
IntPtr region = GdiNative.CreateRoundRectRgn(0, 0, width + 1, height + 1, radius * 2, radius * 2);
|
|
if (region == IntPtr.Zero)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!WindowNative.SetWindowRgn(Handle, region, false))
|
|
{
|
|
GdiNative.DeleteObject(region);
|
|
return;
|
|
}
|
|
|
|
_regionSize = new Size(width, height);
|
|
_regionRadius = radius;
|
|
}
|
|
|
|
private void ApplyOpacity()
|
|
{
|
|
var alpha = (byte)Math.Clamp(Math.Round(_settings.Opacity * 255), 0, 255);
|
|
if (alpha == _alpha)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_alpha = alpha;
|
|
WindowNative.SetAlpha(Handle, alpha);
|
|
}
|
|
}
|