Files
cursor-lang/CursorLang/Services/TrayIcon.cs
T
2026-08-12 04:59:22 +05:00

182 lines
5.4 KiB
C#

using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Interop;
using CursorLang.Interop;
namespace CursorLang.Services;
/// <summary>
/// The icon in the notification area: a way to bring the settings window back and
/// the only way to quit the application.
/// </summary>
/// <remarks>
/// The icon belongs to a window rather than to a process, and the application has no
/// window that is always there — the settings window is hidden most of the time.
/// So a window of its own is created here and never shown: it exists to receive the
/// messages of the icon.
///
/// The menu is a WPF one rather than a system one on purpose: that way it obeys the
/// theme and the language chosen in the settings, like the rest of the interface.
/// </remarks>
public sealed class TrayIcon : ITrayIcon, 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 readonly ILocalizationService _localization;
private HwndSource? _source;
private ContextMenu? _menu;
private IntPtr _icon;
private bool _isInstalled;
public TrayIcon(ILocalizationService localization) => _localization = localization;
public event EventHandler? OpenRequested;
public event EventHandler? ExitRequested;
public bool Install()
{
if (_isInstalled)
{
return true;
}
_source ??= CreateMessageWindow();
_icon = TrayIconNative.LoadApplicationIcon();
_isInstalled = TrayIconNative.Add(_source.Handle, IconId, _icon, Tooltip);
return _isInstalled;
}
public void Dispose()
{
if (_source is null)
{
return;
}
if (_isInstalled)
{
TrayIconNative.Remove(_source.Handle, IconId);
_isInstalled = false;
}
TrayIconNative.ReleaseIcon(_icon);
_icon = IntPtr.Zero;
_source.RemoveHook(OnMessage);
_source.Dispose();
_source = null;
}
/// <summary>
/// The menu of the icon. Built on the first ask: the application starts more
/// often than the user reaches for the menu.
/// </summary>
internal ContextMenu Menu => _menu ??= CreateMenu();
private HwndSource CreateMessageWindow()
{
// A window with no WS_VISIBLE 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
var parameters = new HwndSourceParameters("CursorLang tray")
{
WindowStyle = unchecked((int)0x80000000), // WS_POPUP
Width = 0,
Height = 0,
};
var source = new HwndSource(parameters);
source.AddHook(OnMessage);
return source;
}
private IntPtr OnMessage(IntPtr window, int message, IntPtr wParam, IntPtr lParam, ref bool handled)
{
// Explorer has restarted and taken every icon down with it
if (message == TrayIconNative.TaskbarCreatedMessage && _isInstalled)
{
TrayIconNative.Add(window, IconId, _icon, Tooltip);
handled = true;
return IntPtr.Zero;
}
if (message != TrayIconNative.CallbackMessage)
{
return IntPtr.Zero;
}
switch (TrayIconNative.NotificationOf(lParam))
{
case TrayIconNative.SelectNotification:
case TrayIconNative.KeySelectNotification:
OpenRequested?.Invoke(this, EventArgs.Empty);
handled = true;
break;
case TrayIconNative.ContextMenuNotification:
ShowMenu();
handled = true;
break;
}
return IntPtr.Zero;
}
/// <summary>
/// Raises the menu of the icon where the pointer is.
/// </summary>
internal void ShowMenu()
{
if (_source is not null)
{
TrayIconNative.BringToForeground(_source.Handle);
}
// The menu goes where the pointer is. Windows does report the point of the
// click, but in physical pixels, while WPF places a popup in its own units —
// and the two only agree on a monitor scaled at 100%
Menu.Placement = PlacementMode.MousePoint;
Menu.IsOpen = true;
}
private ContextMenu CreateMenu()
{
var menu = new ContextMenu();
menu.Items.Add(Item("TrayMenuSettings", (_, _) => OpenRequested?.Invoke(this, EventArgs.Empty)));
menu.Items.Add(new Separator());
menu.Items.Add(Item("TrayMenuExit", (_, _) => ExitRequested?.Invoke(this, EventArgs.Empty)));
return menu;
}
// The caption is bound rather than assigned: the language is changed in the
// settings without a restart, and the menu is built once
private MenuItem Item(string key, RoutedEventHandler onClick)
{
var item = new MenuItem();
item.SetBinding(HeaderedItemsControl.HeaderProperty, new Binding($"[{key}]")
{
Source = _localization,
});
item.Click += onClick;
return item;
}
}