added tray menu
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user