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:
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user