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