425 lines
15 KiB
C#
425 lines
15 KiB
C#
using System.Drawing;
|
|
using System.Runtime.InteropServices;
|
|
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.
|
|
///
|
|
/// The picture is drawn into an off-screen bitmap and handed to the window whole, by
|
|
/// <c>UpdateLayeredWindow</c>. Painting on demand instead — a <c>WM_PAINT</c> after the
|
|
/// window is shown — is what the first version did, and it had the popup appear holding
|
|
/// the picture of the previous show: hiding a window does not throw its content away,
|
|
/// and the content is always the other layout. Here there is nothing to be stale,
|
|
/// because the window is never shown before its picture is in place.
|
|
///
|
|
/// It also does away with two devices the painted version needed: the corners came from
|
|
/// a window region, which cuts without antialiasing, and the opacity from
|
|
/// <c>SetLayeredWindowAttributes</c>. Both are now just pixels in the bitmap.
|
|
///
|
|
/// 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 IntPtr _font;
|
|
private double _fontSize;
|
|
private double _fontScale;
|
|
|
|
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;
|
|
}
|
|
|
|
PopupWindowNative.Rect? anchor = TryGetAnchor();
|
|
PopupModeSettings mode = ModeFor(anchor);
|
|
|
|
PopupWindowNative.Rect work = default;
|
|
double scale;
|
|
|
|
if (anchor is { } at)
|
|
{
|
|
scale = PopupWindowNative.GetScaleAt(new PopupWindowNative.Point { X = at.Left, Y = at.Top });
|
|
}
|
|
else
|
|
{
|
|
(work, scale) = PopupWindowNative.GetActiveMonitorWorkArea();
|
|
}
|
|
|
|
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 = anchor is { } near
|
|
? PopupLayout.NearAnchor(
|
|
near,
|
|
SideForMode(),
|
|
PopupLayout.ToPixels(mode.Offset, scale),
|
|
width,
|
|
height)
|
|
: PopupLayout.OnScreen(
|
|
work,
|
|
_settings.FixedPoint.Position,
|
|
PopupLayout.ToPixels(mode.Offset, scale),
|
|
width,
|
|
height);
|
|
|
|
if (!Draw(mode, text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!WindowNative.IsWindowVisible(Handle))
|
|
{
|
|
WindowNative.ShowWindow(Handle, WindowNative.SW_SHOWNOACTIVATE);
|
|
}
|
|
}
|
|
|
|
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();
|
|
base.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws the popup off screen and hands the finished picture to the window.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The bitmap is thrown away afterwards rather than kept: it is a few tens of
|
|
/// 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.
|
|
/// </remarks>
|
|
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)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
IntPtr memory = IntPtr.Zero;
|
|
IntPtr surface = IntPtr.Zero;
|
|
|
|
try
|
|
{
|
|
memory = GdiNative.CreateCompatibleDC(screen);
|
|
if (memory == IntPtr.Zero)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
surface = GdiNative.CreateSurface(memory, width, height, out IntPtr bits);
|
|
if (surface == IntPtr.Zero)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
GdiNative.SelectObject(memory, surface);
|
|
|
|
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
|
|
// popup is opaque anyway, so the whole surface is simply declared so — and
|
|
// the corners are rounded off afterwards, which is the only place alpha
|
|
// varies
|
|
MakeOpaque(bits, width, height);
|
|
RoundTheCorners(bits, width, height, radius);
|
|
|
|
var size = new WindowNative.Size { Width = width, Height = height };
|
|
var alpha = (byte)Math.Clamp(Math.Round(mode.Opacity * 255), 0, 255);
|
|
|
|
return WindowNative.SetContent(Handle, at, size, memory, alpha);
|
|
}
|
|
finally
|
|
{
|
|
// The context goes first: a bitmap still selected into one cannot be
|
|
// deleted, and this way that holds however the method was left
|
|
if (memory != IntPtr.Zero)
|
|
{
|
|
GdiNative.DeleteDC(memory);
|
|
}
|
|
|
|
if (surface != IntPtr.Zero)
|
|
{
|
|
GdiNative.DeleteObject(surface);
|
|
}
|
|
|
|
GdiNative.ReleaseDC(IntPtr.Zero, screen);
|
|
}
|
|
}
|
|
|
|
private static void Fill(PopupModeSettings mode, IntPtr bits, int width, int height)
|
|
{
|
|
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
|
|
int packed = (255 << 24) | (background.R << 16) | (background.G << 8) | background.B;
|
|
var row = new int[width];
|
|
Array.Fill(row, packed);
|
|
|
|
for (var y = 0; y < height; y++)
|
|
{
|
|
Marshal.Copy(row, 0, bits + (y * width * 4), width);
|
|
}
|
|
}
|
|
|
|
private void DrawText(
|
|
PopupModeSettings mode, IntPtr deviceContext, string text, int width, int height)
|
|
{
|
|
if (_font == IntPtr.Zero || text.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var bounds = new PopupWindowNative.Rect { Left = 0, Top = 0, Right = width, Bottom = height };
|
|
|
|
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
|
GdiNative.SetBkMode(deviceContext, GdiNative.TRANSPARENT);
|
|
GdiNative.SetTextColor(deviceContext, GdiNative.ToColorRef(mode.ForegroundColor));
|
|
|
|
GdiNative.DrawText(deviceContext, text, text.Length, ref bounds,
|
|
GdiNative.DT_SINGLELINE | GdiNative.DT_CENTER | GdiNative.DT_VCENTER |
|
|
GdiNative.DT_NOPREFIX | GdiNative.DT_NOCLIP);
|
|
|
|
GdiNative.SelectObject(deviceContext, previousFont);
|
|
}
|
|
|
|
private static void MakeOpaque(IntPtr bits, int width, int height)
|
|
{
|
|
var row = new int[width];
|
|
|
|
for (var y = 0; y < height; y++)
|
|
{
|
|
IntPtr line = bits + (y * width * 4);
|
|
Marshal.Copy(line, row, 0, width);
|
|
|
|
for (var x = 0; x < width; x++)
|
|
{
|
|
row[x] = (int)((uint)row[x] | 0xFF000000);
|
|
}
|
|
|
|
Marshal.Copy(row, 0, line, width);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cuts the four corners to a radius, fading the edge rather than stepping it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The painted version cut them with a window region, which is a yes-or-no mask and
|
|
/// left a visible staircase at 200% scale. Here the corner pixels carry a partial
|
|
/// alpha worked out from how far the pixel centre is past the arc, which is what
|
|
/// antialiasing amounts to. The colours are premultiplied to match, as
|
|
/// <c>UpdateLayeredWindow</c> expects.
|
|
/// </remarks>
|
|
private static void RoundTheCorners(IntPtr bits, int width, int height, int radius)
|
|
{
|
|
if (radius <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
radius = Math.Min(radius, Math.Min(width, height) / 2);
|
|
|
|
var row = new int[width];
|
|
|
|
for (var y = 0; y < height; y++)
|
|
{
|
|
bool nearTop = y < radius;
|
|
bool nearBottom = y >= height - radius;
|
|
if (!nearTop && !nearBottom)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
IntPtr line = bits + (y * width * 4);
|
|
Marshal.Copy(line, row, 0, width);
|
|
|
|
double centreY = nearTop ? radius - 0.5 : height - radius - 0.5;
|
|
|
|
for (var x = 0; x < width; x++)
|
|
{
|
|
bool nearLeft = x < radius;
|
|
bool nearRight = x >= width - radius;
|
|
if (!nearLeft && !nearRight)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
double centreX = nearLeft ? radius - 0.5 : width - radius - 0.5;
|
|
double distance = Math.Sqrt(
|
|
((x - centreX) * (x - centreX)) + ((y - centreY) * (y - centreY)));
|
|
|
|
// One pixel of softness across the arc: fully inside, fully outside,
|
|
// and a ramp in between
|
|
double coverage = Math.Clamp(radius - distance + 0.5, 0, 1);
|
|
if (coverage >= 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
row[x] = Premultiply(row[x], coverage);
|
|
}
|
|
|
|
Marshal.Copy(row, 0, line, width);
|
|
}
|
|
}
|
|
|
|
private static int Premultiply(int pixel, double coverage)
|
|
{
|
|
var value = (uint)pixel;
|
|
var alpha = (uint)Math.Round(((value >> 24) & 0xFF) * coverage);
|
|
|
|
uint red = (uint)Math.Round(((value >> 16) & 0xFF) * coverage);
|
|
uint green = (uint)Math.Round(((value >> 8) & 0xFF) * coverage);
|
|
uint blue = (uint)Math.Round((value & 0xFF) * coverage);
|
|
|
|
return (int)((alpha << 24) | (red << 16) | (green << 8) | blue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// What the popup is placed next to, or <c>null</c> when there is nothing: the fixed
|
|
/// 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
|
|
{
|
|
PopupPlacementMode.FixedPoint => null,
|
|
PopupPlacementMode.AtCaret => CaretNative.TryGetCaretRect(),
|
|
_ => 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
|
|
// its own side in its own terms
|
|
private AnchorSide SideForMode() => _settings.PlacementMode == PopupPlacementMode.AtCaret
|
|
? _settings.AtCaret.Anchor
|
|
: _settings.AtCursor.Side;
|
|
|
|
private Size MeasureText(string text)
|
|
{
|
|
IntPtr deviceContext = GdiNative.GetDC(Handle);
|
|
if (deviceContext == IntPtr.Zero)
|
|
{
|
|
return Size.Empty;
|
|
}
|
|
|
|
try
|
|
{
|
|
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
|
|
Size measured = GdiNative.MeasureText(deviceContext, text);
|
|
GdiNative.SelectObject(deviceContext, previousFont);
|
|
|
|
return measured;
|
|
}
|
|
finally
|
|
{
|
|
GdiNative.ReleaseDC(Handle, deviceContext);
|
|
}
|
|
}
|
|
|
|
// 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(PopupModeSettings mode, double scale)
|
|
{
|
|
if (_font != IntPtr.Zero &&
|
|
Math.Abs(_fontSize - mode.FontSize) < 0.01 &&
|
|
Math.Abs(_fontScale - scale) < 0.01)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ReleaseFont();
|
|
|
|
_fontSize = mode.FontSize;
|
|
_fontScale = scale;
|
|
_font = GdiNative.CreateFont(_fontSize, scale);
|
|
}
|
|
|
|
private void ReleaseFont()
|
|
{
|
|
if (_font != IntPtr.Zero)
|
|
{
|
|
GdiNative.DeleteObject(_font);
|
|
_font = IntPtr.Zero;
|
|
}
|
|
}
|
|
}
|