lightweight variant
Pull request / build (pull_request) Successful in 53s

This commit is contained in:
2026-08-12 16:01:54 +05:00
parent a0d3098fe4
commit 6259dbd6b3
146 changed files with 3930 additions and 2275 deletions
+85
View File
@@ -0,0 +1,85 @@
using System.Collections.Concurrent;
using CursorLang.Agent.Interop;
namespace CursorLang.Agent.Windows;
/// <summary>
/// The window the agent lives around: never shown, but it owns the tray icon and it
/// is the way back onto the message loop from a callback.
/// </summary>
/// <remarks>
/// A window with no <c>WS_VISIBLE</c> shows nowhere, yet is a window in every other
/// way. A message-only window would do as well were it not for the news of Explorer
/// restarting: that one is broadcast, and broadcasts pass such windows by.
/// </remarks>
internal sealed class AgentWindow : NativeWindow
{
/// <summary>
/// A message hook. Returning <c>true</c> means the message has been dealt with.
/// </summary>
internal delegate bool MessageFilter(uint message, IntPtr wParam, IntPtr lParam);
private const string ClassName = "CursorLang.Agent.Window";
/// <summary>Drain the queue of posted work. WM_APP is free for the application.</summary>
private const uint WM_INVOKE = WindowNative.WM_APP + 100;
private readonly List<MessageFilter> _filters = [];
private readonly ConcurrentQueue<Action> _posted = new();
internal AgentWindow()
: base(ClassName, "CursorLang agent", WindowNative.WS_POPUP, WindowNative.WS_EX_TOOLWINDOW)
{
}
internal void AddFilter(MessageFilter filter) => _filters.Add(filter);
/// <summary>
/// Runs the action on the message loop, after the current message is done with.
/// </summary>
/// <remarks>
/// This is what the agent has instead of <c>Dispatcher.BeginInvoke</c>. The caller
/// that matters is the keyboard hook: Windows removes a hook whose procedure takes
/// too long, so the procedure only records what happened and the answer — showing
/// the popup, switching the layout — waits for the message after this one.
/// </remarks>
internal void Post(Action action)
{
_posted.Enqueue(action);
WindowNative.PostMessage(Handle, WM_INVOKE, IntPtr.Zero, IntPtr.Zero);
}
/// <summary>Asks the message loop to finish.</summary>
internal void Quit() => WindowNative.PostQuitMessage(0);
protected override bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
{
result = IntPtr.Zero;
if (message == WM_INVOKE)
{
while (_posted.TryDequeue(out Action? action))
{
action();
}
return true;
}
if (message is WindowNative.WM_CLOSE or WindowNative.WM_ENDSESSION)
{
Quit();
return true;
}
foreach (MessageFilter filter in _filters)
{
if (filter(message, wParam, lParam))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,321 @@
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);
}
}
+131
View File
@@ -0,0 +1,131 @@
using CursorLang.Agent.Interop;
using CursorLang.Core.Interop;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Windows;
/// <summary>
/// The icon in the notification area: the way to the settings window and the only way
/// to quit the application.
/// </summary>
/// <remarks>
/// The interop half is the same as it always was — the icon has never known anything
/// about the framework. What changed is the menu: a WPF <c>ContextMenu</c> obeyed the
/// theme and the language chosen in the settings, and a <c>TrackPopupMenuEx</c> menu is
/// drawn by Windows in the system look. The language still reaches it, because the
/// captions are ours; the theme does not, and that is the price of taking the rendering
/// stack out of the background process.
///
/// The captions are read each time the menu is raised rather than once: the language is
/// changed in the settings without a restart, and the menu is built on every click
/// anyway — a menu costs nothing to build and is asked for rarely.
/// </remarks>
internal sealed class NativeTrayIcon : IDisposable
{
/// <summary>Windows shows it under the pointer. The name of the app says enough.</summary>
private const string Tooltip = "CursorLang";
/// <summary>Distinguishes the icon among those of the same window; we have one.</summary>
private const int IconId = 1;
private const int CommandSettings = 1;
private const int CommandExit = 2;
private readonly AgentWindow _window;
private readonly ILocalizationService _localization;
private IntPtr _icon;
private bool _isInstalled;
internal NativeTrayIcon(AgentWindow window, ILocalizationService localization)
{
_window = window;
_localization = localization;
_window.AddFilter(OnMessage);
}
internal event EventHandler? OpenRequested;
internal event EventHandler? ExitRequested;
/// <summary>Whether the shell accepted the icon. Reported by the diagnostics.</summary>
internal bool IsInstalled => _isInstalled;
internal bool Install()
{
if (_isInstalled)
{
return true;
}
_icon = TrayIconNative.LoadApplicationIcon();
_isInstalled = TrayIconNative.Add(_window.Handle, IconId, _icon, Tooltip);
return _isInstalled;
}
public void Dispose()
{
if (_isInstalled)
{
TrayIconNative.Remove(_window.Handle, IconId);
_isInstalled = false;
}
TrayIconNative.ReleaseIcon(_icon);
_icon = IntPtr.Zero;
}
private bool OnMessage(uint message, IntPtr wParam, IntPtr lParam)
{
// Explorer has restarted and taken every icon down with it
if (message == (uint)TrayIconNative.TaskbarCreatedMessage && _isInstalled)
{
TrayIconNative.Add(_window.Handle, IconId, _icon, Tooltip);
return true;
}
if (message != TrayIconNative.CallbackMessage)
{
return false;
}
switch (TrayIconNative.NotificationOf(lParam))
{
case TrayIconNative.SelectNotification:
case TrayIconNative.KeySelectNotification:
OpenRequested?.Invoke(this, EventArgs.Empty);
return true;
case TrayIconNative.ContextMenuNotification:
ShowMenu(TrayIconNative.PointOf(wParam));
return true;
default:
return false;
}
}
/// <summary>Raises the menu of the icon where the pointer is.</summary>
internal void ShowMenu(PopupWindowNative.Point at)
{
MenuNative.Item[] items =
[
new(CommandSettings, _localization["TrayMenuSettings"]),
MenuNative.Item.Separator,
new(CommandExit, _localization["TrayMenuExit"]),
];
switch (MenuNative.Track(_window.Handle, at, items))
{
case CommandSettings:
OpenRequested?.Invoke(this, EventArgs.Empty);
break;
case CommandExit:
ExitRequested?.Invoke(this, EventArgs.Empty);
break;
}
}
}
+99
View File
@@ -0,0 +1,99 @@
using CursorLang.Agent.Interop;
namespace CursorLang.Agent.Windows;
/// <summary>
/// A window with no framework behind it: a registered class, a handle and a window
/// procedure that lands in <see cref="OnMessage"/>.
/// </summary>
/// <remarks>
/// Windows knows one procedure per class, so the procedure here is shared and static,
/// and finds the instance by handle. The very first message of a window arrives while
/// <c>CreateWindowExW</c> is still running and there is nothing to find yet — that is
/// what the field holding the instance under construction is for.
///
/// Everything is deliberately without locks: the agent has one message loop, every
/// window belongs to it, and a window procedure can only ever be called on the thread
/// that created the window.
/// </remarks>
internal abstract class NativeWindow : IDisposable
{
private static readonly Dictionary<IntPtr, NativeWindow> Live = [];
private static readonly HashSet<string> RegisteredClasses = new(StringComparer.Ordinal);
// The shared procedure is a static field for the same reason a hook procedure is:
// Windows holds the only reference to it and the collector does not see that
private static readonly WindowNative.WindowProc SharedProc = StaticWindowProc;
[ThreadStatic]
private static NativeWindow? _creating;
protected NativeWindow(string className, string title, int style, int exStyle)
{
if (RegisteredClasses.Add(className))
{
WindowNative.RegisterClass(className, SharedProc);
}
_creating = this;
try
{
Handle = WindowNative.CreateWindow(className, title, style, exStyle);
}
finally
{
_creating = null;
}
Live[Handle] = this;
}
/// <summary>The window handle. Zero once the window is gone.</summary>
internal IntPtr Handle { get; private set; }
public virtual void Dispose()
{
if (Handle == IntPtr.Zero)
{
return;
}
IntPtr handle = Handle;
Handle = IntPtr.Zero;
Live.Remove(handle);
WindowNative.DestroyWindow(handle);
}
/// <summary>
/// A message for this window. Returning <c>false</c> passes it to
/// <c>DefWindowProcW</c>, which is what the vast majority of messages want.
/// </summary>
protected abstract bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result);
private static IntPtr StaticWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam)
{
if (!Live.TryGetValue(hWnd, out NativeWindow? window))
{
if (_creating is null)
{
return WindowNative.DefWindowProc(hWnd, message, wParam, lParam);
}
// The window is being created right now: bind the handle to the instance
// so that the rest of its creation messages find their way home
window = _creating;
window.Handle = hWnd;
Live[hWnd] = window;
}
if (message == WindowNative.WM_DESTROY)
{
Live.Remove(hWnd);
}
return window.OnMessage(message, wParam, lParam, out IntPtr result)
? result
: WindowNative.DefWindowProc(hWnd, message, wParam, lParam);
}
}