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
+129
View File
@@ -0,0 +1,129 @@
using System.ComponentModel;
using CursorLang.Agent.Services;
using CursorLang.Agent.Windows;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Core.Threading;
namespace CursorLang.Agent;
/// <summary>
/// The background half of CursorLang: the hook, the layout polling, the popup and the
/// tray icon, with a Win32 message loop underneath and no WPF anywhere.
/// </summary>
/// <remarks>
/// The services are wired by hand rather than through a container, and that is a
/// decision rather than an omission: the whole point of this process is how little it
/// weighs, and a container is a megabyte of assembly and a graph of reflection on the
/// way to the same object. There are ten of them and they are all listed here.
/// </remarks>
internal sealed class Agent : IDisposable
{
private readonly SingleInstanceGate _gate;
private readonly AgentWindow _window;
private readonly SettingsService _settingsService;
private readonly AppSettings _settings;
private readonly LocalizationService _localization;
private readonly NativePopupWindow _popupWindow;
private readonly LayoutPopupService _popupService;
private readonly KeyboardLayoutService _layoutService;
private readonly CapsLockHotkeyService _hotkeyService;
private readonly LayoutNotificationCoordinator _notifications;
private readonly CapsLockSwitchCoordinator _capsLock;
private readonly NativeTrayIcon _tray;
internal Agent(SingleInstanceGate gate)
{
_gate = gate;
_window = new AgentWindow();
_window.AddFilter(OnWindowMessage);
_settingsService = new SettingsService();
_settings = _settingsService.Load();
_localization = new LocalizationService { CurrentLanguage = _settings.Language };
_settings.PropertyChanged += OnSettingsChanged;
_popupWindow = new NativePopupWindow(_settings);
_popupService = new LayoutPopupService(_popupWindow, _settings);
_layoutService = new KeyboardLayoutService(new KeyboardLayoutOptions());
_hotkeyService = new CapsLockHotkeyService(_settings, _window.Post);
_notifications = new LayoutNotificationCoordinator(_layoutService, _popupService);
_capsLock = new CapsLockSwitchCoordinator(_hotkeyService, _layoutService, _popupService, _settings);
_tray = new NativeTrayIcon(_window, _localization);
}
/// <summary>Starts everything and pumps messages until the user asks to quit.</summary>
internal int Run(bool automatic)
{
_gate.ActivationRequested += OnActivationRequested;
_tray.OpenRequested += OnOpenRequested;
_tray.ExitRequested += OnExitRequested;
bool hasTray = _tray.Install();
_notifications.Start();
_capsLock.Start();
if (!automatic || !hasTray)
{
SettingsLauncher.Open();
}
return MessageLoop.Run();
}
public void Dispose()
{
_gate.ActivationRequested -= OnActivationRequested;
_tray.OpenRequested -= OnOpenRequested;
_tray.ExitRequested -= OnExitRequested;
_settings.PropertyChanged -= OnSettingsChanged;
_capsLock.Dispose();
_notifications.Dispose();
_hotkeyService.Dispose();
_layoutService.Dispose();
_popupService.Dispose();
_settingsService.Dispose();
_tray.Dispose();
_window.Dispose();
}
// The settings window has written the file and says so. The write was a single
// atomic move, so there is nothing to wait for and nothing half-written to read
private bool OnWindowMessage(uint message, IntPtr wParam, IntPtr lParam)
{
if (message != SettingsSignal.Message)
{
return false;
}
_settingsService.Reload();
return true;
}
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(AppSettings.Language))
{
_localization.CurrentLanguage = _settings.Language;
}
}
// A second launch of the agent, from the Start menu for instance. The one already
// running answers the way the user expects a second launch to be answered
private void OnActivationRequested(object? sender, EventArgs e) =>
_window.Post(() => SettingsLauncher.Open());
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
private void OnExitRequested(object? sender, EventArgs e) => _window.Quit();
}
+54
View File
@@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<RootNamespace>CursorLang.Agent</RootNamespace>
<AssemblyName>CursorLang</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\CursorLang.Core\Resources\CursorLang.ico</ApplicationIcon>
<PlatformTarget>AnyCPU</PlatformTarget>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun Condition="'$(RuntimeIdentifier)' == 'win-x64'">true</PublishReadyToRun>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Product>CursorLang</Product>
<Company>Aleksandr Neychev</Company>
<Description>Shows the keyboard layout at the cursor</Description>
<Copyright>Copyright (c) 2026</Copyright>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Core\CursorLang.Core.csproj"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CursorLang.Settings\CursorLang.Settings.csproj"
ReferenceOutputAssembly="false"
Private="false"/>
</ItemGroup>
<Target Name="PlaceTheSettingsWindowBesideTheAgent" AfterTargets="Build">
<ItemGroup>
<SettingsOutput Include="..\CursorLang.Settings\bin\$(Configuration)\$(TargetFramework)\**\*"/>
</ItemGroup>
<Copy SourceFiles="@(SettingsOutput)"
DestinationFolder="$(OutDir)%(RecursiveDir)"
SkipUnchangedFiles="true"/>
</Target>
<ItemGroup>
<InternalsVisibleTo Include="CursorLang.Agent.Tests"/>
</ItemGroup>
</Project>
+137
View File
@@ -0,0 +1,137 @@
using System.Drawing;
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// Plain GDI: a font, a brush, a rounded region and text on a device context.
/// </summary>
/// <remarks>
/// GDI rather than GDI+ on purpose. <c>System.Drawing.Common</c> would make the drawing
/// code shorter, but it is a separate assembly with a native GDI+ library behind it, and
/// how little this process weighs is the entire reason it exists apart from the window.
/// Everything the popup needs — one rounded rectangle and one line of text — GDI can do
/// on its own, and it draws the text with ClearType, exactly as Windows does everywhere else.
/// </remarks>
internal static class GdiNative
{
internal const int TRANSPARENT = 1;
internal const uint DT_SINGLELINE = 0x00000020;
internal const uint DT_CENTER = 0x00000001;
internal const uint DT_VCENTER = 0x00000004;
internal const uint DT_CALCRECT = 0x00000400;
internal const uint DT_NOPREFIX = 0x00000800;
internal const uint DT_NOCLIP = 0x00000100;
private const int DEFAULT_CHARSET = 1;
private const int OUT_TT_PRECIS = 4;
private const int CLIP_DEFAULT_PRECIS = 0;
private const int CLEARTYPE_QUALITY = 5;
private const int DEFAULT_PITCH = 0;
/// <summary>
/// The face the popup is written in.
/// </summary>
/// <remarks>
/// WPF is asked for "Segoe UI" at FontWeight SemiBold and resolves that to the
/// seguisb.ttf face. To GDI that face is a family of its own — "Segoe UI Semibold" —
/// and asking for the "Segoe UI" family at weight 600 lands on Bold instead, which
/// is visibly heavier. So the family is named outright and the weight is left to the
/// mapper: the family has one member and no synthetic emboldening happens.
/// </remarks>
private const string SemiBoldFace = "Segoe UI Semibold";
private const int FW_DONTCARE = 0;
/// <summary>
/// A font of the given size in physical pixels.
/// </summary>
/// <remarks>
/// The size in the settings is in WPF units, that is 1/96 inch, while GDI counts
/// pixels — hence the multiplication by the monitor scale. The height is negative:
/// that asks for the em size rather than the cell height, which is what a font size
/// means everywhere else.
/// </remarks>
internal static IntPtr CreateFont(double wpfFontSize, double scale)
{
var height = (int)Math.Round(wpfFontSize * scale);
return CreateFontW(
-height, 0, 0, 0, FW_DONTCARE,
false, false, false,
DEFAULT_CHARSET, OUT_TT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH,
SemiBoldFace);
}
/// <summary>The size of a single line of text with the font selected into the context.</summary>
internal static Size MeasureText(IntPtr deviceContext, string text)
{
var bounds = default(PopupWindowNative.Rect);
DrawText(deviceContext, text, text.Length, ref bounds,
DT_CALCRECT | DT_SINGLELINE | DT_NOPREFIX);
return new Size(bounds.Right - bounds.Left, bounds.Bottom - bounds.Top);
}
/// <summary>A colour as GDI wants it: 0x00BBGGRR, the alpha carried elsewhere.</summary>
internal static uint ToColorRef(Color color) =>
(uint)(color.R | (color.G << 8) | (color.B << 16));
[DllImport("gdi32.dll", CharSet = CharSet.Unicode, EntryPoint = "CreateFontW")]
private static extern IntPtr CreateFontW(int cHeight, int cWidth, int cEscapement, int cOrientation,
int cWeight, bool bItalic, bool bUnderline, bool bStrikeOut,
int iCharSet, int iOutPrecision, int iClipPrecision, int iQuality, int iPitchAndFamily,
string pszFaceName);
[DllImport("gdi32.dll")]
internal static extern IntPtr CreateSolidBrush(uint color);
[DllImport("gdi32.dll")]
internal static extern IntPtr CreateRoundRectRgn(int x1, int y1, int x2, int y2, int w, int h);
[DllImport("gdi32.dll")]
internal static extern IntPtr SelectObject(IntPtr hdc, IntPtr h);
[DllImport("gdi32.dll")]
internal static extern bool DeleteObject(IntPtr ho);
[DllImport("gdi32.dll")]
internal static extern int SetBkMode(IntPtr hdc, int mode);
[DllImport("gdi32.dll")]
internal static extern uint SetTextColor(IntPtr hdc, uint color);
[DllImport("user32.dll")]
internal static extern int FillRect(IntPtr hdc, ref PopupWindowNative.Rect lprc, IntPtr hbr);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DrawTextW")]
internal static extern int DrawText(IntPtr hdc, string lpchText, int cchText,
ref PopupWindowNative.Rect lprc, uint format);
[DllImport("user32.dll")]
internal static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
[DllImport("user32.dll")]
internal static extern IntPtr BeginPaint(IntPtr hWnd, out PaintStruct lpPaint);
[DllImport("user32.dll")]
internal static extern bool EndPaint(IntPtr hWnd, ref PaintStruct lpPaint);
[StructLayout(LayoutKind.Sequential)]
internal struct PaintStruct
{
public IntPtr hdc;
public bool fErase;
public PopupWindowNative.Rect rcPaint;
public bool fRestore;
public bool fIncUpdate;
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
public byte[] rgbReserved;
}
}
+98
View File
@@ -0,0 +1,98 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// The system context menu — the one the tray icon raises.
/// </summary>
/// <remarks>
/// A menu built this way is drawn by Windows, so the theme and the language chosen in
/// the application no longer reach it. That is the accepted price of leaving WPF: a WPF
/// <c>ContextMenu</c> costs the whole rendering stack in the background process.
/// </remarks>
internal static class MenuNative
{
private const uint MF_STRING = 0x00000000;
private const uint MF_SEPARATOR = 0x00000800;
private const uint MF_GRAYED = 0x00000001;
private const uint TPM_LEFTALIGN = 0x0000;
private const uint TPM_RIGHTBUTTON = 0x0002;
private const uint TPM_RETURNCMD = 0x0100;
/// <summary>An item of the menu being built.</summary>
/// <param name="Id">What <see cref="Track"/> gives back when the item is chosen.</param>
/// <param name="Caption">The text, or <c>null</c> for a separator.</param>
/// <param name="IsEnabled">A greyed item is shown but cannot be chosen.</param>
internal readonly record struct Item(int Id, string? Caption, bool IsEnabled = true)
{
internal static Item Separator => new(0, null);
}
/// <summary>
/// Raises the menu at a screen point and returns the identifier of the chosen item,
/// or zero when the user dismissed it.
/// </summary>
/// <remarks>
/// <c>TPM_RETURNCMD</c> means the answer comes back from the call itself instead of
/// as a <c>WM_COMMAND</c> later, which keeps the whole menu in one place. The call
/// does not return until the user is done with the menu — that is how a modal menu
/// works, and the message loop keeps running inside it.
///
/// The window is brought to the foreground first and poked with an empty message
/// afterwards: without the first the menu never closes on a click elsewhere, and
/// without the second it stays on screen after the choice is made. Both are
/// long-standing quirks of a menu owned by a window the user cannot see.
/// </remarks>
internal static int Track(IntPtr owner, PopupWindowNative.Point at, IReadOnlyList<Item> items)
{
IntPtr menu = CreatePopupMenu();
if (menu == IntPtr.Zero)
{
return 0;
}
try
{
foreach (Item item in items)
{
if (item.Caption is null)
{
AppendMenu(menu, MF_SEPARATOR, IntPtr.Zero, null);
continue;
}
uint flags = MF_STRING | (item.IsEnabled ? 0 : MF_GRAYED);
AppendMenu(menu, flags, new IntPtr(item.Id), item.Caption);
}
TrayIconNative.BringToForeground(owner);
int chosen = TrackPopupMenuEx(
menu, TPM_LEFTALIGN | TPM_RIGHTBUTTON | TPM_RETURNCMD,
at.X, at.Y, owner, IntPtr.Zero);
WindowNative.PostMessage(owner, WindowNative.WM_NULL, IntPtr.Zero, IntPtr.Zero);
return chosen;
}
finally
{
DestroyMenu(menu);
}
}
[DllImport("user32.dll")]
private static extern IntPtr CreatePopupMenu();
[DllImport("user32.dll")]
private static extern bool DestroyMenu(IntPtr hMenu);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "AppendMenuW")]
private static extern bool AppendMenu(IntPtr hMenu, uint uFlags, IntPtr uIDNewItem, string? lpNewItem);
[DllImport("user32.dll")]
private static extern int TrackPopupMenuEx(IntPtr hMenu, uint uFlags, int x, int y,
IntPtr hwnd, IntPtr lptpm);
}
+237
View File
@@ -0,0 +1,237 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// Win32 API for the notification area: the icon itself, the messages it sends
/// and the icon image taken from the executable.
/// </summary>
/// <remarks>
/// The icon is asked for at version 4 of the protocol. It is the only version that
/// reports a request for the context menu as such — by the keyboard as well as by
/// the right button — and passes the point of the click along with it. The
/// notification then arrives in the low word of <c>lParam</c>, and the point in
/// <c>wParam</c>, which is the opposite of the earlier versions.
/// </remarks>
internal static class TrayIconNative
{
/// <summary>The message the icon sends to its window. WM_APP is free for the app.</summary>
internal const int CallbackMessage = 0x8000 + 1;
/// <summary>The user chose the icon: a click of the left button or Enter on it.</summary>
internal const int SelectNotification = 0x0400;
/// <summary>The same by the space bar — Windows tells the two apart.</summary>
internal const int KeySelectNotification = 0x0403;
/// <summary>The context menu is asked for: the right button or the menu key.</summary>
internal const int ContextMenuNotification = 0x007B;
private const int NIM_ADD = 0x00000000;
private const int NIM_MODIFY = 0x00000001;
private const int NIM_DELETE = 0x00000002;
private const int NIM_SETVERSION = 0x00000004;
private const uint NIF_MESSAGE = 0x00000001;
private const uint NIF_ICON = 0x00000002;
private const uint NIF_TIP = 0x00000004;
private const uint NIF_SHOWTIP = 0x00000080;
private const uint NotifyIconVersion4 = 4;
private const uint IMAGE_ICON = 1;
private const uint LR_DEFAULTCOLOR = 0x00000000;
private const int SM_CXSMICON = 49;
private const int SM_CYSMICON = 50;
/// <summary>The resource the .NET build puts the application icon under.</summary>
private const int ApplicationIconResource = 32512;
/// <summary>
/// Explorer says it has restarted this way. The icons of every application are
/// gone by then and have to be put back.
/// </summary>
internal static int TaskbarCreatedMessage { get; } = RegisterWindowMessage("TaskbarCreated");
/// <summary>Puts the icon into the notification area.</summary>
internal static bool Add(IntPtr window, int id, IntPtr icon, string tooltip)
{
NotifyIconData data = Describe(window, id, icon, tooltip);
if (!Shell_NotifyIcon(NIM_ADD, ref data))
{
return false;
}
// The version is asked for after the icon is added and applies to it alone
data.uVersion = NotifyIconVersion4;
Shell_NotifyIcon(NIM_SETVERSION, ref data);
return true;
}
/// <summary>Replaces the image and the tooltip of an icon already there.</summary>
internal static bool Modify(IntPtr window, int id, IntPtr icon, string tooltip)
{
NotifyIconData data = Describe(window, id, icon, tooltip);
return Shell_NotifyIcon(NIM_MODIFY, ref data);
}
/// <summary>Takes the icon away. A forgotten icon stays in the tray until hovered.</summary>
internal static void Remove(IntPtr window, int id)
{
var data = new NotifyIconData
{
cbSize = Marshal.SizeOf<NotifyIconData>(),
hWnd = window,
uID = (uint)id,
};
Shell_NotifyIcon(NIM_DELETE, ref data);
}
/// <summary>
/// The icon of the application at the size the tray asks for.
/// </summary>
/// <remarks>
/// The image comes from the executable itself, so the tray shows what the user
/// sees in Explorer. The build puts the icon under the standard resource; should
/// it end up elsewhere, the first icon of the file is taken, and failing that —
/// the icon Windows gives to an application without one. An icon is needed
/// either way: without it the tray shows an empty spot.
/// </remarks>
internal static IntPtr LoadApplicationIcon()
{
int width = GetSystemMetrics(SM_CXSMICON);
int height = GetSystemMetrics(SM_CYSMICON);
IntPtr icon = LoadImage(
GetModuleHandle(null), ApplicationIconResource, IMAGE_ICON, width, height, LR_DEFAULTCOLOR);
if (icon == IntPtr.Zero && Environment.ProcessPath is { Length: > 0 } path)
{
icon = ExtractIconEx(path, 0, out IntPtr large, out IntPtr small, 1) > 0 ? small : IntPtr.Zero;
if (large != IntPtr.Zero)
{
DestroyIcon(large);
}
}
return icon != IntPtr.Zero ? icon : LoadIcon(IntPtr.Zero, ApplicationIconResource);
}
/// <summary>Releases an icon loaded by <see cref="LoadApplicationIcon"/>.</summary>
internal static void ReleaseIcon(IntPtr icon)
{
if (icon != IntPtr.Zero)
{
DestroyIcon(icon);
}
}
/// <summary>The notification the icon has sent: it sits in the low word of lParam.</summary>
internal static int NotificationOf(IntPtr lParam) => (int)(lParam.ToInt64() & 0xFFFF);
/// <summary>
/// The point of the click. Version 4 of the protocol reports it in screen pixels
/// in <c>wParam</c> — exactly what <c>TrackPopupMenuEx</c> expects.
/// </summary>
internal static PopupWindowNative.Point PointOf(IntPtr wParam) => new()
{
X = (short)(wParam.ToInt64() & 0xFFFF),
Y = (short)((wParam.ToInt64() >> 16) & 0xFFFF),
};
/// <summary>
/// Brings the window to the foreground.
/// </summary>
/// <remarks>
/// Windows takes a menu down when its owner window loses the foreground. A tray
/// icon belongs to a window that is never shown, so the foreground has to be
/// asked for by hand — otherwise the menu stays on screen after the user has
/// clicked past it.
/// </remarks>
internal static void BringToForeground(IntPtr window) => SetForegroundWindow(window);
private static NotifyIconData Describe(IntPtr window, int id, IntPtr icon, string tooltip) => new()
{
cbSize = Marshal.SizeOf<NotifyIconData>(),
hWnd = window,
uID = (uint)id,
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
uCallbackMessage = CallbackMessage,
hIcon = icon,
szTip = tooltip,
};
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIcon(int dwMessage, ref NotifyIconData lpData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int RegisterWindowMessage(string lpString);
[DllImport("user32.dll")]
private static extern IntPtr LoadImage(IntPtr hInst, IntPtr name, uint type, int cx, int cy, uint fuLoad);
private static IntPtr LoadImage(IntPtr hInst, int resource, uint type, int cx, int cy, uint fuLoad) =>
LoadImage(hInst, new IntPtr(resource), type, cx, cy, fuLoad);
[DllImport("user32.dll")]
private static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName);
private static IntPtr LoadIcon(IntPtr hInstance, int resource) =>
LoadIcon(hInstance, new IntPtr(resource));
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int ExtractIconEx(string lpszFile, int nIconIndex,
out IntPtr phiconLarge, out IntPtr phiconSmall, int nIcons);
[DllImport("user32.dll")]
private static extern bool DestroyIcon(IntPtr hIcon);
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
/// <summary>
/// NOTIFYICONDATAW. The whole structure is described even though only its first
/// half is used: Windows reads its size and refuses one it does not know.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconData
{
public int cbSize;
public IntPtr hWnd;
public uint uID;
public uint uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public uint dwState;
public uint dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
/// <summary>A timeout in the older versions and the protocol version here.</summary>
public uint uVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public uint dwInfoFlags;
public Guid guidItem;
public IntPtr hBalloonIcon;
}
}
+150
View File
@@ -0,0 +1,150 @@
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// The Win32 pieces a window needs when there is no framework to make one:
/// the class, the window itself, the message loop.
/// </summary>
internal static class WindowNative
{
/// <summary>The window procedure. Windows keeps the only reference to it.</summary>
internal delegate IntPtr WindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
internal const int WS_POPUP = unchecked((int)0x80000000);
internal const int WS_DISABLED = 0x08000000;
internal const int WS_EX_LAYERED = 0x00080000;
internal const int WS_EX_TOOLWINDOW = 0x00000080;
internal const int WS_EX_NOACTIVATE = 0x08000000;
internal const int WS_EX_TRANSPARENT = 0x00000020;
internal const int WS_EX_TOPMOST = 0x00000008;
internal const int SW_HIDE = 0;
internal const int SW_SHOWNOACTIVATE = 4;
internal const uint WM_DESTROY = 0x0002;
internal const uint WM_PAINT = 0x000F;
internal const uint WM_CLOSE = 0x0010;
internal const uint WM_QUIT = 0x0012;
internal const uint WM_NULL = 0x0000;
internal const uint WM_DISPLAYCHANGE = 0x007E;
internal const uint WM_DPICHANGED = 0x02E0;
internal const uint WM_ENDSESSION = 0x0016;
/// <summary>WM_APP and up belong to the application; the tray takes WM_APP + 1.</summary>
internal const uint WM_APP = 0x8000;
private const uint LWA_ALPHA = 0x00000002;
/// <summary>
/// Registers a window class. A class already there is not an error: the name is
/// unique per window kind, and a second agent in the same process would meet its
/// own registration.
/// </summary>
internal static void RegisterClass(string className, WindowProc windowProc)
{
var description = new WindowClass
{
cbSize = Marshal.SizeOf<WindowClass>(),
lpfnWndProc = windowProc,
hInstance = GetModuleHandle(null),
lpszClassName = className,
};
if (RegisterClassEx(ref description) == 0 &&
Marshal.GetLastWin32Error() != ErrorClassAlreadyExists)
{
throw new InvalidOperationException(
$"RegisterClassExW failed for '{className}': {Marshal.GetLastWin32Error()}");
}
}
private const int ErrorClassAlreadyExists = 1410;
/// <summary>Creates a window of a registered class. It is not shown.</summary>
internal static IntPtr CreateWindow(string className, string title, int style, int exStyle)
{
IntPtr window = CreateWindowEx(
exStyle, className, title, style,
0, 0, 0, 0,
IntPtr.Zero, IntPtr.Zero, GetModuleHandle(null), IntPtr.Zero);
if (window == IntPtr.Zero)
{
throw new InvalidOperationException(
$"CreateWindowExW failed for '{className}': {Marshal.GetLastWin32Error()}");
}
return window;
}
/// <summary>The whole opacity of a layered window, 0 to 255.</summary>
internal static void SetAlpha(IntPtr window, byte alpha) =>
SetLayeredWindowAttributes(window, 0, alpha, LWA_ALPHA);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern ushort RegisterClassEx(ref WindowClass lpwcx);
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true, EntryPoint = "CreateWindowExW")]
private static extern IntPtr CreateWindowEx(int dwExStyle, string lpClassName, string lpWindowName,
int dwStyle, int x, int y, int nWidth, int nHeight,
IntPtr hWndParent, IntPtr hMenu, IntPtr hInstance, IntPtr lpParam);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "DefWindowProcW")]
internal static extern IntPtr DefWindowProc(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
internal static extern bool DestroyWindow(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
internal static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern bool InvalidateRect(IntPtr hWnd, IntPtr lpRect, bool bErase);
[DllImport("user32.dll")]
internal static extern bool UpdateWindow(IntPtr hWnd);
[DllImport("user32.dll")]
internal static extern bool SetWindowRgn(IntPtr hWnd, IntPtr hRgn, bool bRedraw);
[DllImport("user32.dll")]
internal static extern bool GetClientRect(IntPtr hWnd, out PopupWindowNative.Rect lpRect);
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "PostMessageW")]
internal static extern bool PostMessage(IntPtr hWnd, uint message, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll")]
internal static extern void PostQuitMessage(int nExitCode);
[DllImport("user32.dll")]
private static extern bool SetLayeredWindowAttributes(IntPtr hWnd, uint crKey, byte bAlpha, uint dwFlags);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WindowClass
{
public int cbSize;
public uint style;
[MarshalAs(UnmanagedType.FunctionPtr)]
public WindowProc lpfnWndProc;
public int cbClsExtra;
public int cbWndExtra;
public IntPtr hInstance;
public IntPtr hIcon;
public IntPtr hCursor;
public IntPtr hbrBackground;
public string? lpszMenuName;
public string lpszClassName;
public IntPtr hIconSm;
}
}
+38
View File
@@ -0,0 +1,38 @@
using CursorLang.Core.Services;
namespace CursorLang.Agent;
internal static class Program
{
/// <summary>
/// A single-threaded apartment because of the caret: <c>AccessibleObjectFromWindow</c>
/// and UI Automation both go through COM, and both expect the thread that calls them
/// to be an STA one.
/// </summary>
[STAThread]
private static int Main(string[] arguments)
{
bool automatic = StartupLaunch.IsAutomatic(arguments);
var gate = new SingleInstanceGate(SingleInstanceGate.AgentName);
// A second launch is the user asking for the application, so the one already
// running opens the settings window and this one steps aside. A second launch
// by Windows at sign-in asks for nothing and gets nothing
if (!gate.TryAcquire(showRunningInstance: !automatic))
{
gate.Dispose();
return 0;
}
try
{
using var agent = new Agent(gate);
return agent.Run(automatic);
}
finally
{
gate.Dispose();
}
}
}
@@ -0,0 +1,11 @@
{
"profiles": {
"Agent": {
"commandName": "Project"
},
"Agent (started by Windows)": {
"commandName": "Project",
"commandLineArgs": "--startup"
}
}
}
@@ -0,0 +1,148 @@
using CursorLang.Core.Interop;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Core.Threading;
namespace CursorLang.Agent.Services;
/// <summary>
/// Holds the system Caps Lock hook and tells a short tap from a hold.
/// </summary>
/// <remarks>
/// They can be told apart only by the key being released, so both events are
/// intercepted — the press and the release. That is also the only way to cancel the
/// case change: Windows toggles Caps Lock on the press event, and letting it through
/// "just in case" is not an option.
///
/// Two things changed on the way out of WPF: the hold is timed by
/// <see cref="MessageTimer"/>, and the event reaches its subscribers through a message
/// posted to the agent's window rather than through the dispatcher.
/// </remarks>
public sealed class CapsLockHotkeyService : ICapsLockHotkeyService, IDisposable
{
private const int VirtualKeyCapsLock = 0x14;
private readonly AppSettings _settings;
private readonly Action<Action> _post;
private readonly LowLevelKeyboardHook _hook;
private readonly MessageTimer _holdTimer = new();
private bool _isPressed;
private bool _isHolding;
/// <param name="settings">Where the hold threshold is read from, on every press.</param>
/// <param name="post">
/// Hands work back to the message loop. Taken as a delegate rather than as the
/// agent's window so that the press logic can be checked without one.
/// </param>
public CapsLockHotkeyService(AppSettings settings, Action<Action> post)
{
_settings = settings;
_post = post;
_hook = new LowLevelKeyboardHook(HandleKeyEvent);
_holdTimer.Tick += OnHoldTimerTick;
}
public event EventHandler? Tapped;
public event EventHandler? HoldStarted;
public event EventHandler? HoldEnded;
public bool IsRunning => _hook.IsInstalled;
public void Start() => _hook.Install();
public void Stop()
{
_hook.Uninstall();
ResetPress();
}
// When the application is closing, nobody is waiting for events any more, so
// unlike in Stop the state is reset quietly: the message loop is already gone by
// that moment and posted work would never run
public void Dispose()
{
_holdTimer.Tick -= OnHoldTimerTick;
_holdTimer.Dispose();
_isPressed = false;
_isHolding = false;
_hook.Dispose();
}
// Called by the system hook, that is, inside message queue processing. Only state
// tracking belongs here: showing windows and raising events from here is not
// allowed — the handler must return control within a few milliseconds.
// In tests the key presses are fed here as well: there is no need to install a
// real keyboard hook just to check how presses are interpreted
internal bool HandleKeyEvent(int virtualKey, bool isKeyDown)
{
if (virtualKey != VirtualKeyCapsLock)
{
return false;
}
if (isKeyDown)
{
// While the key is held, Windows repeats the press: the hold is counted
// from the first event and the repeats are ignored
if (!_isPressed)
{
_isPressed = true;
// The threshold is read on every press: it is changed in the settings on the fly
_holdTimer.Interval = _settings.CapsLockHoldDelay;
_holdTimer.Start();
}
return true;
}
_isPressed = false;
_holdTimer.Stop();
if (_isHolding)
{
_isHolding = false;
Notify(HoldEnded);
}
else
{
Notify(Tapped);
}
return true;
}
private void OnHoldTimerTick(object? sender, EventArgs e)
{
_holdTimer.Stop();
_isHolding = true;
HoldStarted?.Invoke(this, EventArgs.Empty);
}
// The event reaches the subscribers after the hook returns: they are free to show
// windows and do anything else without holding up the handling of the key press
private void Notify(EventHandler? handler)
{
if (handler is not null)
{
_post(() => handler(this, EventArgs.Empty));
}
}
// The hook may have been removed with the key held down — by clearing the
// checkbox in the settings, for instance. The popup has to be taken down then
private void ResetPress()
{
_isPressed = false;
_holdTimer.Stop();
if (_isHolding)
{
_isHolding = false;
Notify(HoldEnded);
}
}
}
@@ -0,0 +1,64 @@
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Core.Threading;
namespace CursorLang.Agent.Services;
/// <summary>
/// Manages the lifetime of the popup: the window is only responsible for showing it,
/// while the decision of when to show and when to take it down is made here.
/// </summary>
/// <remarks>
/// The same service it always was, with <c>DispatcherTimer</c> swapped for
/// <see cref="MessageTimer"/>: both tick on the thread that owns the window, so
/// nothing else about the logic had to move.
/// </remarks>
public sealed class LayoutPopupService : ILayoutPopupService, IDisposable
{
private readonly ILayoutPopupWindow _window;
private readonly AppSettings _settings;
private readonly MessageTimer _hideTimer = new();
public LayoutPopupService(ILayoutPopupWindow window, AppSettings settings)
{
_window = window;
_settings = settings;
_hideTimer.Tick += OnHideTimerTick;
}
public void Show(KeyboardLayout layout)
{
ShowUntilHidden(layout);
// The duration is read on every show: it is changed in the settings on the fly.
// Restarting the timer also prolongs the show on quick switches
_hideTimer.Interval = _settings.Duration;
_hideTimer.Start();
}
public void ShowUntilHidden(KeyboardLayout layout)
{
_hideTimer.Stop();
_window.ShowPopup(layout.ShortName);
}
public void Hide()
{
_hideTimer.Stop();
_window.Hide();
}
public void Dispose()
{
_hideTimer.Tick -= OnHideTimerTick;
_hideTimer.Dispose();
_window.Close();
}
private void OnHideTimerTick(object? sender, EventArgs e)
{
_hideTimer.Stop();
_window.Hide();
}
}
@@ -0,0 +1,43 @@
using System.ComponentModel;
using System.Diagnostics;
using CursorLang.Core.Services;
namespace CursorLang.Agent.Services;
/// <summary>
/// Starts the settings window.
/// </summary>
/// <remarks>
/// The agent does not keep track of whether the window is already open, and does not
/// need to: the settings process guards a single-instance slot of its own, so a second
/// launch raises the window already there and exits. That costs a process start to find
/// out, which is a fraction of the time it takes a person to look at the tray, and it
/// saves the agent from holding a handle to something it does not own.
/// </remarks>
internal static class SettingsLauncher
{
/// <summary>
/// Opens the settings window. Returns <c>false</c> when the executable is not
/// where it should be — a half-copied installation, or the agent run from a build
/// folder of its own.
/// </summary>
internal static bool Open()
{
if (AgentExecutable.SettingsPath is not { } path)
{
return false;
}
try
{
using Process? started = Process.Start(new ProcessStartInfo(path) { UseShellExecute = false });
return started is not null;
}
catch (Exception e) when (e is Win32Exception or InvalidOperationException)
{
// Nothing to tell the user with: the agent has no window of its own, and
// the one that would have shown the message is the one that failed to start
return false;
}
}
}
+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);
}
}
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="CursorLang.Agent.app" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
</windowsSettings>
</application>
</assembly>