fix bug - popup windows show hiding logic
Pull request / build (pull_request) Successful in 39s

This commit is contained in:
2026-08-12 18:12:08 +05:00
parent 6259dbd6b3
commit 87266e6c13
13 changed files with 503 additions and 442 deletions
+218 -147
View File
@@ -1,4 +1,5 @@
using System.Drawing;
using System.Runtime.InteropServices;
using CursorLang.Agent.Interop;
using CursorLang.Agent.Services;
using CursorLang.Core.Interop;
@@ -14,15 +15,18 @@ namespace CursorLang.Agent.Windows;
/// 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.
/// from the settings.
///
/// 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.
/// 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"/>.
@@ -38,16 +42,9 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
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(
@@ -71,8 +68,6 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
return;
}
_text = text;
bool atFixedPoint = _settings.PlacementMode == PopupPlacementMode.FixedPoint;
PopupWindowNative.Rect work = default;
PopupWindowNative.Rect anchor = default;
@@ -90,7 +85,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
EnsureFont(scale);
Size measured = MeasureText();
Size measured = MeasureText(text);
int width = measured.Width + (2 * PopupLayout.ToPixels(PaddingX, scale));
int height = measured.Height + (2 * PopupLayout.ToPixels(PaddingY, scale));
@@ -99,21 +94,15 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
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 (!Draw(text, position, width, height, PopupLayout.ToPixels(CornerRadius, scale)))
{
return;
}
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()
@@ -130,27 +119,205 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
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)
/// <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(string text, PopupWindowNative.Point at, int width, int height, int radius)
{
result = IntPtr.Zero;
if (message != WindowNative.WM_PAINT)
IntPtr screen = GdiNative.GetDC(IntPtr.Zero);
if (screen == IntPtr.Zero)
{
return false;
}
Paint();
return true;
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(bits, width, height);
DrawText(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(_settings.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 void Fill(IntPtr bits, int width, int height)
{
Color background = _settings.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(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(_settings.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);
}
// The anchor point: the caret in the input field or the mouse cursor. The cursor
@@ -174,42 +341,7 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
_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()
private Size MeasureText(string text)
{
IntPtr deviceContext = GdiNative.GetDC(Handle);
if (deviceContext == IntPtr.Zero)
@@ -220,10 +352,10 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
try
{
IntPtr previousFont = GdiNative.SelectObject(deviceContext, _font);
Size size = GdiNative.MeasureText(deviceContext, _text);
Size measured = GdiNative.MeasureText(deviceContext, text);
GdiNative.SelectObject(deviceContext, previousFont);
return size;
return measured;
}
finally
{
@@ -231,13 +363,13 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
}
}
// 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
// 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
private void EnsureFont(double scale)
{
if (_font != IntPtr.Zero &&
Math.Abs(_fontSize - _settings.FontSize) < 0.001 &&
Math.Abs(_fontScale - scale) < 0.001)
Math.Abs(_fontSize - _settings.FontSize) < 0.01 &&
Math.Abs(_fontScale - scale) < 0.01)
{
return;
}
@@ -251,71 +383,10 @@ internal sealed class NativePopupWindow : NativeWindow, ILayoutPopupWindow
private void ReleaseFont()
{
if (_font == IntPtr.Zero)
if (_font != IntPtr.Zero)
{
return;
GdiNative.DeleteObject(_font);
_font = IntPtr.Zero;
}
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);
}
}