added tray menu
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The icon in the notification area — the only face of an application that works
|
||||
/// in the background.
|
||||
/// </summary>
|
||||
public interface ITrayIcon
|
||||
{
|
||||
/// <summary>The user asks for the settings window: the icon or its menu.</summary>
|
||||
event EventHandler? OpenRequested;
|
||||
|
||||
/// <summary>The user asks the application to quit. There is no other way out.</summary>
|
||||
event EventHandler? ExitRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Puts the icon into the notification area. Returns <c>false</c> when Windows
|
||||
/// refuses to take it.
|
||||
/// </summary>
|
||||
bool Install();
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the settings window between the screen and the tray.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The application works in the background, and the settings window is a guest on
|
||||
/// the screen rather than the application itself. Both buttons in its title bar
|
||||
/// therefore mean the same thing — put the window away — and neither ends the
|
||||
/// application: that is what the tray menu is for.
|
||||
///
|
||||
/// The window is hidden rather than closed. Closing it would take its handle with
|
||||
/// it, and everything hung on the window — the theme, the place it was left in, the
|
||||
/// computed layout — would have to be built anew on every show.
|
||||
/// </remarks>
|
||||
public sealed class MainWindowPresenter
|
||||
{
|
||||
private readonly MainWindowPlacement _placement;
|
||||
|
||||
private Window? _window;
|
||||
|
||||
// The application is quitting: the window may close for real now
|
||||
private bool _isExiting;
|
||||
|
||||
public MainWindowPresenter(MainWindowPlacement placement) => _placement = placement;
|
||||
|
||||
/// <summary>Whether the window is on the screen right now.</summary>
|
||||
public bool IsShown => _window is { IsVisible: true };
|
||||
|
||||
/// <summary>Takes over the fate of the window: the close button and the minimise one.</summary>
|
||||
public void Attach(Window window)
|
||||
{
|
||||
_window = window;
|
||||
|
||||
window.Closing += OnClosing;
|
||||
window.StateChanged += OnStateChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the window and brings it forward — from the tray as well as from a
|
||||
/// second launch of the application.
|
||||
/// </summary>
|
||||
public void Show()
|
||||
{
|
||||
if (_window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_window.Show();
|
||||
|
||||
// A window put away while minimised is restored first: only then does it
|
||||
// have bounds to be placed by
|
||||
if (_window.WindowState == WindowState.Minimized)
|
||||
{
|
||||
_window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
_placement.Apply(_window);
|
||||
_window.Activate();
|
||||
}
|
||||
|
||||
/// <summary>Puts the window away into the tray.</summary>
|
||||
public void Hide()
|
||||
{
|
||||
if (_window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_window.Hide();
|
||||
|
||||
// The state is straightened out after the window is out of sight: the next
|
||||
// show is to bring back a window, not an icon on the taskbar
|
||||
_window.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets the window close. Called on the way out through the tray menu — up to
|
||||
/// that moment closing means hiding.
|
||||
/// </summary>
|
||||
public void AllowClose() => _isExiting = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gives the window back its usual behaviour: the close button closes, and the
|
||||
/// minimise button minimises.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The last resort for a session with no notification area in it. Without the
|
||||
/// icon there is no way back to a hidden window and no way out of the
|
||||
/// application — the window has to take both duties back.
|
||||
/// </remarks>
|
||||
public void Detach()
|
||||
{
|
||||
if (_window is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_window.Closing -= OnClosing;
|
||||
_window.StateChanged -= OnStateChanged;
|
||||
_window = null;
|
||||
}
|
||||
|
||||
private void OnClosing(object? sender, CancelEventArgs e)
|
||||
{
|
||||
if (_isExiting)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
}
|
||||
|
||||
private void OnStateChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_window is { WindowState: WindowState.Minimized })
|
||||
{
|
||||
Hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,13 @@ internal sealed class RegistryStartup
|
||||
/// What Windows is to run. <c>null</c> — the path of the running program is
|
||||
/// unknown, and there is nothing to write down.
|
||||
/// </summary>
|
||||
private static string? GetCommand() =>
|
||||
Environment.ProcessPath is { Length: > 0 } path ? $"\"{path}\"" : null;
|
||||
/// <remarks>
|
||||
/// The argument is how the application recognises a launch of this kind and goes
|
||||
/// straight to the tray: see <see cref="StartupLaunch"/>. The user starting the
|
||||
/// application themselves passes no such thing and gets the window.
|
||||
/// </remarks>
|
||||
internal static string? GetCommand() =>
|
||||
Environment.ProcessPath is { Length: > 0 } path
|
||||
? $"\"{path}\" {StartupLaunch.Argument}"
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,17 @@ public sealed class SingleInstanceGate : IDisposable
|
||||
/// Takes the single-instance slot. When the application is already running, asks
|
||||
/// it to show itself and returns <c>false</c> — the caller is left to exit.
|
||||
/// </summary>
|
||||
public bool TryAcquire()
|
||||
public bool TryAcquire() => TryAcquire(showRunningInstance: true);
|
||||
|
||||
/// <summary>
|
||||
/// The same, with a say in what is to happen to the application already running.
|
||||
/// </summary>
|
||||
/// <param name="showRunningInstance">
|
||||
/// Whether the running application is to be brought up. A launch by Windows
|
||||
/// itself passes <c>false</c>: it was not asked for a window, and the
|
||||
/// application already in the tray is answer enough.
|
||||
/// </param>
|
||||
public bool TryAcquire(bool showRunningInstance)
|
||||
{
|
||||
_mutex = new Mutex(initiallyOwned: false, _mutexName);
|
||||
|
||||
@@ -73,8 +83,12 @@ public sealed class SingleInstanceGate : IDisposable
|
||||
|
||||
if (!_isOwner)
|
||||
{
|
||||
ForegroundPermissionNative.GrantToAnyProcess();
|
||||
_activationRequest.Set();
|
||||
if (showRunningInstance)
|
||||
{
|
||||
ForegroundPermissionNative.GrantToAnyProcess();
|
||||
_activationRequest.Set();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CursorLang.Interop;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.ApplicationModel.Activation;
|
||||
|
||||
namespace CursorLang.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Whether Windows started the application by itself.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A launch of its own accord ends up in the tray without a window: the user asked
|
||||
/// for the application to be there when they sign in, not for a window to greet them
|
||||
/// every morning. A launch by the user is another matter — the window is what they
|
||||
/// clicked for.
|
||||
///
|
||||
/// The two builds tell the launches apart differently. A build in a folder is
|
||||
/// started from the registry, and the command written there carries an argument of
|
||||
/// its own — see <see cref="RegistryStartup"/>. A package has no say in its command
|
||||
/// line, and Windows is asked about the activation instead.
|
||||
/// </remarks>
|
||||
internal static class StartupLaunch
|
||||
{
|
||||
/// <summary>What the registry entry adds to the path of the application.</summary>
|
||||
internal const string Argument = "--startup";
|
||||
|
||||
/// <summary>Whether this launch is the doing of Windows rather than of the user.</summary>
|
||||
internal static bool IsAutomatic(IReadOnlyList<string> arguments) =>
|
||||
HasArgument(arguments) || IsStartupActivation();
|
||||
|
||||
/// <summary>The command line says the launch comes from the startup entry.</summary>
|
||||
internal static bool HasArgument(IReadOnlyList<string> arguments) =>
|
||||
arguments.Any(argument => string.Equals(argument, Argument, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private static bool IsStartupActivation()
|
||||
{
|
||||
if (!PackageIdentityNative.IsPackaged)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return AppInstance.GetActivatedEventArgs() is { Kind: ActivationKind.StartupTask };
|
||||
}
|
||||
catch (Exception e) when (e is COMException or InvalidOperationException or NotSupportedException)
|
||||
{
|
||||
// Windows has nothing to say about the activation. A window shown when it
|
||||
// was not asked for is a smaller mishap than an application that hides
|
||||
// when the user has just started it
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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