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; /// /// The icon in the notification area: a way to bring the settings window back and /// the only way to quit the application. /// /// /// 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. /// public sealed class TrayIcon : ITrayIcon, IDisposable { /// Windows shows it under the pointer. The name of the app says enough. private const string Tooltip = "CursorLang"; /// Distinguishes the icon among those of the same window; we have one. 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; } /// /// The menu of the icon. Built on the first ask: the application starts more /// often than the user reaches for the menu. /// 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; } /// /// Raises the menu of the icon where the pointer is. /// 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; } }