lightweight variant (#1)

Reviewed-on: #1
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 13:37:30 +00:00
committed by alex
parent a0d3098fe4
commit 55ac8e6556
147 changed files with 4055 additions and 2349 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>
+161
View File
@@ -0,0 +1,161 @@
using System.Drawing;
using System.Runtime.InteropServices;
using CursorLang.Core.Interop;
namespace CursorLang.Agent.Interop;
/// <summary>
/// Plain GDI: a font, text, and an off-screen bitmap to draw them into.
/// </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);
/// <summary>
/// A 32-bit surface to draw the popup into before anyone can see it.
/// </summary>
/// <remarks>
/// Top-down — a negative height — so that the first row of <paramref name="bits"/>
/// is the top row of the picture and the alpha fixing up afterwards can walk the
/// memory straight through.
/// </remarks>
internal static IntPtr CreateSurface(IntPtr deviceContext, int width, int height, out IntPtr bits)
{
var header = new BitmapInfoHeader
{
biSize = Marshal.SizeOf<BitmapInfoHeader>(),
biWidth = width,
biHeight = -height,
biPlanes = 1,
biBitCount = 32,
biCompression = BI_RGB,
};
return CreateDIBSection(deviceContext, ref header, DIB_RGB_COLORS, out bits, IntPtr.Zero, 0);
}
private const uint BI_RGB = 0;
private const uint DIB_RGB_COLORS = 0;
[DllImport("gdi32.dll")]
internal static extern IntPtr CreateCompatibleDC(IntPtr hdc);
[DllImport("gdi32.dll")]
internal static extern bool DeleteDC(IntPtr hdc);
[DllImport("gdi32.dll")]
private static extern IntPtr CreateDIBSection(IntPtr hdc, ref BitmapInfoHeader header, uint usage,
out IntPtr bits, IntPtr section, uint offset);
[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", 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);
[StructLayout(LayoutKind.Sequential)]
private struct BitmapInfoHeader
{
public int biSize;
public int biWidth;
public int biHeight;
public short biPlanes;
public short biBitCount;
public uint biCompression;
public uint biSizeImage;
public int biXPelsPerMeter;
public int biYPelsPerMeter;
public uint biClrUsed;
public uint biClrImportant;
}
}
+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);
}
+229
View File
@@ -0,0 +1,229 @@
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_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>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;
}
}
+180
View File
@@ -0,0 +1,180 @@
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_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_CLOSE = 0x0010;
internal const uint WM_QUIT = 0x0012;
internal const uint WM_NULL = 0x0000;
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;
/// <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>
/// Puts a finished picture into a layered window, together with where it goes and
/// how see-through it is.
/// </summary>
/// <remarks>
/// One call replaces moving the window, resizing it, painting it and setting its
/// opacity, and it works while the window is still hidden. That is the point: the
/// content is ready before anyone can see the window, so it can never be shown
/// holding the picture of the previous time.
/// </remarks>
internal static bool SetContent(
IntPtr window, PopupWindowNative.Point at, Size size, IntPtr sourceDc, byte alpha)
{
var source = new PopupWindowNative.Point { X = 0, Y = 0 };
var blend = new BlendFunction
{
BlendOp = AC_SRC_OVER,
SourceConstantAlpha = alpha,
AlphaFormat = AC_SRC_ALPHA,
};
return UpdateLayeredWindow(
window, IntPtr.Zero, ref at, ref size, sourceDc, ref source, 0, ref blend, ULW_ALPHA);
}
private const byte AC_SRC_OVER = 0;
private const byte AC_SRC_ALPHA = 1;
private const uint ULW_ALPHA = 0x00000002;
/// <summary>
/// BLENDFUNCTION. <c>BlendFlags</c> is never assigned and must stay all the same:
/// Windows reads the structure by its layout, and dropping a byte from the middle
/// of it would shift everything after.
/// </summary>
[StructLayout(LayoutKind.Sequential)]
private struct BlendFunction
{
public byte BlendOp;
public byte BlendFlags;
public byte SourceConstantAlpha;
public byte AlphaFormat;
}
[StructLayout(LayoutKind.Sequential)]
internal struct Size
{
public int Width;
public int Height;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern bool UpdateLayeredWindow(IntPtr hWnd, IntPtr hdcDst,
ref PopupWindowNative.Point pptDst, ref Size psize, IntPtr hdcSrc,
ref PopupWindowNative.Point pptSrc, uint crKey, ref BlendFunction pblend, uint dwFlags);
[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", 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("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,167 @@
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) => HandleHoldElapsed();
/// <summary>
/// The hold countdown has run out.
/// </summary>
/// <remarks>
/// The key being down is checked rather than assumed. A countdown started on the
/// press can still be delivered just after the release — Windows does not withdraw a
/// WM_TIMER it has already posted — and announcing a hold then would put the popup on
/// screen showing the layout the tap is about to change away from.
///
/// The tests reach this directly: that ordering is the whole point and a real clock
/// will not reproduce it on demand.
/// </remarks>
internal void HandleHoldElapsed()
{
_holdTimer.Stop();
if (!_isPressed)
{
return;
}
_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,392 @@
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;
}
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(text);
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);
if (!Draw(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(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(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
// 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 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
private void EnsureFont(double scale)
{
if (_font != IntPtr.Zero &&
Math.Abs(_fontSize - _settings.FontSize) < 0.01 &&
Math.Abs(_fontScale - scale) < 0.01)
{
return;
}
ReleaseFont();
_fontSize = _settings.FontSize;
_fontScale = scale;
_font = GdiNative.CreateFont(_fontSize, scale);
}
private void ReleaseFont()
{
if (_font != IntPtr.Zero)
{
GdiNative.DeleteObject(_font);
_font = IntPtr.Zero;
}
}
}
+128
View File
@@ -0,0 +1,128 @@
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;
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;
}
}
}
+104
View File
@@ -0,0 +1,104 @@
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 — and
/// what all of them want for a window whose content is set from the outside.
/// </summary>
protected virtual bool OnMessage(uint message, IntPtr wParam, IntPtr lParam, out IntPtr result)
{
result = IntPtr.Zero;
return false;
}
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>