added tray menu

This commit is contained in:
2026-08-12 04:59:22 +05:00
parent 08192d317e
commit a0d3098fe4
21 changed files with 1327 additions and 42 deletions
+12
View File
@@ -18,6 +18,9 @@ public sealed class AppTests
[InlineData(typeof(ThemeService))]
[InlineData(typeof(IThemeService))]
[InlineData(typeof(MainWindowPlacement))]
[InlineData(typeof(MainWindowPresenter))]
[InlineData(typeof(TrayIcon))]
[InlineData(typeof(ITrayIcon))]
[InlineData(typeof(ILocalizationService))]
[InlineData(typeof(IStartupService))]
[InlineData(typeof(IUpdateService))]
@@ -100,6 +103,15 @@ public sealed class AppTests
Assert.NotNull(window.ImplementationFactory);
}
[Fact]
public void The_tray_icon_and_its_interface_are_one_icon()
{
ServiceDescriptor tray = Describe()
.Single(descriptor => descriptor.ServiceType == typeof(ITrayIcon));
Assert.NotNull(tray.ImplementationFactory);
}
[Fact]
public void The_theme_and_its_interface_are_one_service()
{
+42 -12
View File
@@ -3,6 +3,7 @@ using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using CursorLang.Services;
namespace CursorLang.Tests;
@@ -25,6 +26,9 @@ public sealed class EndToEndTests
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
/// <summary>How long "the application went on working" is worth watching for.</summary>
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
[Fact]
public void The_application_starts_and_shows_the_settings_window()
{
@@ -34,6 +38,22 @@ public sealed class EndToEndTests
Assert.False(launch.Process.HasExited);
}
/// <summary>
/// Started by Windows itself, the application goes straight to the tray: the
/// user asked for it to be there, not for a window to greet them.
/// </summary>
[Fact]
public void A_launch_by_Windows_shows_no_window()
{
using Launch launch = Launch.Start(StartupLaunch.Argument);
// Nothing is expected to appear, so the wait is for the whole time
Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended by itself");
launch.Process.Refresh();
Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
}
// A second run raises no second window but shows the window of the running one
[Fact]
public void The_second_run_ends_by_itself()
@@ -50,15 +70,19 @@ public sealed class EndToEndTests
Assert.False(launch.Process.HasExited);
}
// The way out of the application is the tray menu alone: the close button of
// the window merely puts the window away
[Fact]
public void Closing_the_window_ends_the_application()
public void Closing_the_window_leaves_the_application_in_the_tray()
{
using Launch launch = Launch.Start();
launch.WaitForWindow();
Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close");
Assert.True(launch.Process.WaitForExit(ExitTimeout), "the application did not end after the window closed");
Assert.Equal(0, launch.Process.ExitCode);
Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended together with its window");
launch.Process.Refresh();
Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
}
/// <summary>A started application that shuts down together with the check.</summary>
@@ -79,7 +103,7 @@ public sealed class EndToEndTests
internal Process Process { get; }
/// <summary>Starts the application first — making sure the place is free.</summary>
internal static Launch Start()
internal static Launch Start(params string[] arguments)
{
if (!HasInteractiveDesktop())
{
@@ -91,11 +115,11 @@ public sealed class EndToEndTests
Assert.Skip("The application is already running — this check keeps out of someone else's run");
}
return new Launch(StartProcess());
return new Launch(StartProcess(arguments));
}
/// <summary>Starts the application the way the user does.</summary>
internal static Process StartProcess()
/// <summary>Starts the application the way the user — or Windows — does.</summary>
internal static Process StartProcess(params string[] arguments)
{
string path = ExecutablePath();
@@ -104,7 +128,14 @@ public sealed class EndToEndTests
Assert.Skip($"The application is not built: {path}");
}
return Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })!;
var start = new ProcessStartInfo(path) { UseShellExecute = true };
foreach (string argument in arguments)
{
start.ArgumentList.Add(argument);
}
return Process.Start(start)!;
}
/// <summary>
@@ -171,14 +202,13 @@ public sealed class EndToEndTests
{
if (!Process.HasExited)
{
// The polite way first — that way the app gets to save its settings
if (!Process.CloseMainWindow() || !Process.WaitForExit(ExitTimeout))
{
// Asking the window to close would only put it away into the
// tray, and the exit lives in a menu no test can reach. The
// settings are saved as they change, so nothing is lost here
Process.Kill(entireProcessTree: true);
Process.WaitForExit(ExitTimeout);
}
}
}
catch (InvalidOperationException)
{
// The process has already ended on its own
@@ -0,0 +1,158 @@
using System.Windows;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <summary>
/// The fate of the settings window: it comes and goes, and the application
/// outlives it either way.
/// </summary>
/// <remarks>
/// The window has to be alive for real — showing and hiding are the point here —
/// so it is kept fully transparent and off the taskbar.
/// </remarks>
public sealed class MainWindowPresenterTests
{
[Fact]
public void The_close_button_hides_the_window_instead_of_closing_it()
{
Sta.Run(() =>
{
var presenter = new MainWindowPresenter(new MainWindowPlacement());
Open(presenter, window =>
{
window.Close();
Assert.False(window.IsVisible);
Assert.False(presenter.IsShown);
// The window is hidden rather than closed: a closed one would
// refuse to be shown again
presenter.Show();
Assert.True(window.IsVisible);
});
});
}
[Fact]
public void The_minimise_button_hides_the_window_as_well()
{
Sta.Run(() =>
{
var presenter = new MainWindowPresenter(new MainWindowPlacement());
Open(presenter, window =>
{
window.WindowState = WindowState.Minimized;
Assert.False(window.IsVisible);
// And the window is left in the state the next show needs:
// a minimised one would come back as an icon on the taskbar
Assert.Equal(WindowState.Normal, window.WindowState);
});
});
}
[Fact]
public void A_hidden_window_comes_back_on_request()
{
Sta.Run(() =>
{
var presenter = new MainWindowPresenter(new MainWindowPlacement());
Open(presenter, window =>
{
presenter.Hide();
Assert.False(presenter.IsShown);
presenter.Show();
Assert.True(window.IsVisible);
Assert.True(presenter.IsShown);
Assert.Equal(WindowState.Normal, window.WindowState);
});
});
}
// On the way out of the application the window closes for real: otherwise
// the exit from the tray menu would run into the very same refusal
[Fact]
public void The_window_closes_once_the_application_is_quitting()
{
Sta.Run(() =>
{
var presenter = new MainWindowPresenter(new MainWindowPlacement());
Open(presenter, window =>
{
presenter.AllowClose();
window.Close();
Assert.Throws<InvalidOperationException>(window.Show);
});
});
}
// Without a notification area there is no way back to a hidden window,
// so the window goes back to closing when told to close
[Fact]
public void A_window_let_go_of_behaves_the_usual_way()
{
Sta.Run(() =>
{
var presenter = new MainWindowPresenter(new MainWindowPlacement());
Open(presenter, window =>
{
presenter.Detach();
window.Close();
Assert.Throws<InvalidOperationException>(window.Show);
});
// Letting go twice is what happens when the application quits
// right after: it is no reason to fail
presenter.Detach();
});
}
[Fact]
public void With_no_window_attached_nothing_happens()
{
var presenter = new MainWindowPresenter(new MainWindowPlacement());
presenter.Show();
presenter.Hide();
Assert.False(presenter.IsShown);
}
private static void Open(MainWindowPresenter presenter, Action<Window> check)
{
var window = new Window
{
// The window is needed alive, but not in sight
Opacity = 0,
ShowInTaskbar = false,
ShowActivated = false,
Width = 100,
Height = 100,
};
presenter.Attach(window);
try
{
window.Show();
check(window);
}
finally
{
presenter.AllowClose();
window.Close();
}
}
}
@@ -0,0 +1,60 @@
using CursorLang.Services;
namespace CursorLang.Tests.Services;
/// <summary>
/// Telling a launch by Windows apart from a launch by the user: the first one goes
/// to the tray without a window, the second one is what the window is for.
/// </summary>
public sealed class StartupLaunchTests
{
[Fact]
public void A_launch_by_the_user_carries_no_argument()
{
Assert.False(StartupLaunch.HasArgument([]));
Assert.False(StartupLaunch.IsAutomatic([]));
}
[Fact]
public void The_startup_entry_says_so_in_the_command_line()
{
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument]));
Assert.True(StartupLaunch.IsAutomatic([StartupLaunch.Argument]));
}
// The argument does not have to come first: Windows may put its own
// alongside it one day
[Fact]
public void The_argument_is_looked_for_among_the_others()
{
Assert.True(StartupLaunch.HasArgument(["--whatever", StartupLaunch.Argument]));
}
[Fact]
public void The_case_of_the_argument_does_not_matter()
{
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument.ToUpperInvariant()]));
}
[Fact]
public void Anything_else_is_a_launch_by_the_user()
{
Assert.False(StartupLaunch.HasArgument(["--startupp", "startup", "-startup"]));
}
/// <summary>
/// The command written into the registry carries the argument: that is the whole
/// point of the argument.
/// </summary>
[Fact]
public void The_startup_entry_is_written_with_the_argument()
{
string? command = RegistryStartup.GetCommand();
Assert.NotNull(command);
Assert.EndsWith(StartupLaunch.Argument, command, StringComparison.Ordinal);
// And the path itself stays quoted: it has spaces in it more often than not
Assert.StartsWith("\"", command, StringComparison.Ordinal);
}
}
+213
View File
@@ -0,0 +1,213 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using CursorLang.Interop;
using CursorLang.Services;
using CursorLang.Tests.Infrastructure;
namespace CursorLang.Tests.Services;
/// <summary>
/// The icon in the notification area and its menu — the whole interface of an
/// application that works in the background.
/// </summary>
/// <remarks>
/// The icon needs a window of its own, so the checks run on the interface thread.
/// Whether the tray accepts the icon is up to Windows: there is no notification area
/// in a session without a desktop, and such a check skips itself.
/// </remarks>
public sealed class TrayIconTests
{
[Fact]
public void The_icon_ends_up_in_the_notification_area()
{
Sta.Run(() =>
{
using var tray = new TrayIcon(new FakeLocalizationService());
if (!tray.Install())
{
Assert.Skip("Windows did not take the icon — there is no notification area here");
}
// Asking twice changes nothing: a second icon of the same
// application in the tray is not what anyone wants
Assert.True(tray.Install());
});
}
[Fact]
public void The_icon_is_taken_away_on_the_way_out()
{
Sta.Run(() =>
{
var tray = new TrayIcon(new FakeLocalizationService());
tray.Install();
tray.Dispose();
// A second disposal is what the container does after the application
// has already shut the tray down itself
tray.Dispose();
});
}
[Fact]
public void The_menu_offers_the_settings_and_the_way_out()
{
Sta.Run(() =>
{
using var tray = new TrayIcon(new FakeLocalizationService());
List<MenuItem> items = [.. tray.Menu.Items.OfType<MenuItem>()];
Assert.Equal(2, items.Count);
Assert.Equal("en:TrayMenuSettings", items[0].Header);
Assert.Equal("en:TrayMenuExit", items[1].Header);
// The way out is set apart from the rest: it is the one point of no return
Assert.Single(tray.Menu.Items.OfType<Separator>());
});
}
[Fact]
public void The_menu_follows_the_language_chosen_in_the_settings()
{
Sta.Run(() =>
{
var localization = new FakeLocalizationService();
using var tray = new TrayIcon(localization);
MenuItem settings = tray.Menu.Items.OfType<MenuItem>().First();
Assert.Equal("en:TrayMenuSettings", settings.Header);
localization.CurrentLanguage = "ru";
Assert.Equal("ru:TrayMenuSettings", settings.Header);
});
}
[Fact]
public void The_settings_point_of_the_menu_asks_for_the_window()
{
Sta.Run(() =>
{
using var tray = new TrayIcon(new FakeLocalizationService());
int opened = 0;
int exits = 0;
tray.OpenRequested += (_, _) => opened++;
tray.ExitRequested += (_, _) => exits++;
Click(tray.Menu.Items.OfType<MenuItem>().First());
Assert.Equal(1, opened);
Assert.Equal(0, exits);
});
}
[Fact]
public void The_exit_point_of_the_menu_asks_for_the_way_out()
{
Sta.Run(() =>
{
using var tray = new TrayIcon(new FakeLocalizationService());
int opened = 0;
int exits = 0;
tray.OpenRequested += (_, _) => opened++;
tray.ExitRequested += (_, _) => exits++;
Click(tray.Menu.Items.OfType<MenuItem>().Last());
Assert.Equal(1, exits);
Assert.Equal(0, opened);
});
}
// The menu belongs to no window and hangs on no element tree: WPF has to
// raise it all the same
[Fact]
public void The_menu_shows_up_on_request()
{
Sta.Run(() =>
{
using var tray = new TrayIcon(new FakeLocalizationService());
tray.Install();
try
{
tray.ShowMenu();
Assert.True(tray.Menu.IsOpen);
Assert.Equal(PlacementMode.MousePoint, tray.Menu.Placement);
}
finally
{
// An open menu holds the mouse: the checks that follow would
// never see a click of their own
tray.Menu.IsOpen = false;
}
});
}
/// <summary>
/// The line between the points of the menu is drawn by the theme of the app.
/// </summary>
/// <remarks>
/// WPF gives a separator inside a menu a style of its own, found by a key rather
/// than by the type: a style by type never reaches it. Left with the system one,
/// the line comes out indented from the left, where the icons of a system menu
/// would go, and stretched past the right edge of the menu.
/// </remarks>
[Fact]
public void The_line_between_the_points_of_the_menu_keeps_to_the_theme()
{
Sta.Run(() =>
{
var themed = Application.Current.TryFindResource(MenuItem.SeparatorStyleKey) as Style;
Assert.NotNull(themed);
Assert.Equal(typeof(Separator), themed.TargetType);
using var tray = new TrayIcon(new FakeLocalizationService());
try
{
tray.ShowMenu();
Separator line = tray.Menu.Items.OfType<Separator>().Single();
// The style has reached the line: it is the theme drawing it,
// margins and all
Assert.Same(themed, line.Style);
Assert.True(line.ActualWidth > 0);
}
finally
{
tray.Menu.IsOpen = false;
}
});
}
// The image comes from the executable itself; the icon Windows keeps for an
// application without one is the last resort. An empty spot in the tray is not
// an option either way
[Fact]
public void There_is_an_icon_to_show()
{
IntPtr icon = TrayIconNative.LoadApplicationIcon();
Assert.NotEqual(IntPtr.Zero, icon);
TrayIconNative.ReleaseIcon(icon);
}
[Fact]
public void Explorer_restarting_is_a_message_of_its_own()
{
Assert.NotEqual(0, TrayIconNative.TaskbarCreatedMessage);
}
private static void Click(MenuItem item) => item.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
}
+28 -1
View File
@@ -265,6 +265,28 @@ public sealed class MainWindowTests
});
}
// The application lives in the tray, and the settings window is a guest on the
// screen: its close button puts it away rather than ends anything
[Fact]
public void The_window_hooks_up_to_the_tray()
{
Sta.Run(() =>
{
using SettingsViewModel viewModel = CreateViewModel();
Open(viewModel, window =>
{
window.Close();
Assert.False(window.IsVisible);
// A closed window would refuse this
window.Show();
Assert.True(window.IsVisible);
});
});
}
[Fact]
public void The_window_hooks_up_to_the_placement()
{
@@ -305,7 +327,9 @@ public sealed class MainWindowTests
MainWindowPlacement placement,
Action<MainWindow> check)
{
var window = new MainWindow(viewModel, theme, placement)
var presenter = new MainWindowPresenter(placement);
var window = new MainWindow(viewModel, theme, placement, presenter)
{
// The window is needed alive, but not in sight
Opacity = 0,
@@ -322,6 +346,9 @@ public sealed class MainWindowTests
}
finally
{
// The window belongs to the tray now and refuses to close until
// the application is on its way out
presenter.AllowClose();
window.Close();
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Application x:Class="CursorLang.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnMainWindowClose">
ShutdownMode="OnExplicitShutdown">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
+40 -17
View File
@@ -11,14 +11,17 @@ public partial class App : Application
{
private ServiceProvider? _services;
private SingleInstanceGate? _instanceGate;
private MainWindowPlacement? _placement;
private MainWindowPresenter? _presenter;
private ITrayIcon? _tray;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
bool automatic = StartupLaunch.IsAutomatic(e.Args);
var gate = new SingleInstanceGate();
if (!gate.TryAcquire())
if (!gate.TryAcquire(showRunningInstance: !automatic))
{
gate.Dispose();
Shutdown();
@@ -34,9 +37,26 @@ public partial class App : Application
_services.GetRequiredService<ThemeService>();
_placement = _services.GetRequiredService<MainWindowPlacement>();
_presenter = _services.GetRequiredService<MainWindowPresenter>();
MainWindow = _services.GetRequiredService<MainWindow>();
MainWindow.Show();
_tray = _services.GetRequiredService<ITrayIcon>();
_tray.OpenRequested += OnOpenRequested;
_tray.ExitRequested += OnExitRequested;
bool hasTray = _tray.Install();
if (!automatic || !hasTray)
{
_presenter.Show();
}
if (!hasTray)
{
_presenter.Detach();
ShutdownMode = ShutdownMode.OnMainWindowClose;
}
_ = _services.GetRequiredService<SettingsViewModel>().InitializeAsync();
_ = _services.GetRequiredService<UpdateViewModel>().StartAsync();
@@ -46,6 +66,12 @@ public partial class App : Application
protected override void OnExit(ExitEventArgs e)
{
if (_tray is not null)
{
_tray.OpenRequested -= OnOpenRequested;
_tray.ExitRequested -= OnExitRequested;
}
_services?.Dispose();
if (_instanceGate is not null)
@@ -57,21 +83,14 @@ public partial class App : Application
base.OnExit(e);
}
private void OnActivationRequested(object? sender, EventArgs e)
{
if (MainWindow is not { IsVisible: true })
{
return;
}
private void OnActivationRequested(object? sender, EventArgs e) => _presenter?.Show();
if (MainWindow.WindowState == WindowState.Minimized)
private void OnOpenRequested(object? sender, EventArgs e) => _presenter?.Show();
private void OnExitRequested(object? sender, EventArgs e)
{
MainWindow.WindowState = WindowState.Normal;
}
_placement?.Apply(MainWindow);
MainWindow.Activate();
_presenter?.AllowClose();
Shutdown();
}
internal static void ConfigureServices(IServiceCollection services)
@@ -86,6 +105,10 @@ public partial class App : Application
services.AddSingleton<IThemeService>(provider => provider.GetRequiredService<ThemeService>());
services.AddSingleton<MainWindowPlacement>();
services.AddSingleton<MainWindowPresenter>();
services.AddSingleton<TrayIcon>();
services.AddSingleton<ITrayIcon>(provider => provider.GetRequiredService<TrayIcon>());
services.AddSingleton<ILocalizationService, LocalizationService>();
services.AddSingleton<IStartupService, StartupService>();
+226
View File
@@ -0,0 +1,226 @@
using System.Runtime.InteropServices;
namespace CursorLang.Interop;
/// <summary>
/// Win32 API for the notification area: the icon itself, the messages it sends
/// and the icon image taken from the executable.
/// </summary>
/// <remarks>
/// The icon is asked for at version 4 of the protocol. It is the only version that
/// reports a request for the context menu as such — by the keyboard as well as by
/// the right button — and passes the point of the click along with it. The
/// notification then arrives in the low word of <c>lParam</c>, and the point in
/// <c>wParam</c>, which is the opposite of the earlier versions.
/// </remarks>
internal static class TrayIconNative
{
/// <summary>The message the icon sends to its window. WM_APP is free for the app.</summary>
internal const int CallbackMessage = 0x8000 + 1;
/// <summary>The user chose the icon: a click of the left button or Enter on it.</summary>
internal const int SelectNotification = 0x0400;
/// <summary>The same by the space bar — Windows tells the two apart.</summary>
internal const int KeySelectNotification = 0x0403;
/// <summary>The context menu is asked for: the right button or the menu key.</summary>
internal const int ContextMenuNotification = 0x007B;
private const int NIM_ADD = 0x00000000;
private const int NIM_MODIFY = 0x00000001;
private const int NIM_DELETE = 0x00000002;
private const int NIM_SETVERSION = 0x00000004;
private const uint NIF_MESSAGE = 0x00000001;
private const uint NIF_ICON = 0x00000002;
private const uint NIF_TIP = 0x00000004;
private const uint NIF_SHOWTIP = 0x00000080;
private const uint NotifyIconVersion4 = 4;
private const uint IMAGE_ICON = 1;
private const uint LR_DEFAULTCOLOR = 0x00000000;
private const int SM_CXSMICON = 49;
private const int SM_CYSMICON = 50;
/// <summary>The resource the .NET build puts the application icon under.</summary>
private const int ApplicationIconResource = 32512;
/// <summary>
/// Explorer says it has restarted this way. The icons of every application are
/// gone by then and have to be put back.
/// </summary>
internal static int TaskbarCreatedMessage { get; } = RegisterWindowMessage("TaskbarCreated");
/// <summary>Puts the icon into the notification area.</summary>
internal static bool Add(IntPtr window, int id, IntPtr icon, string tooltip)
{
NotifyIconData data = Describe(window, id, icon, tooltip);
if (!Shell_NotifyIcon(NIM_ADD, ref data))
{
return false;
}
// The version is asked for after the icon is added and applies to it alone
data.uVersion = NotifyIconVersion4;
Shell_NotifyIcon(NIM_SETVERSION, ref data);
return true;
}
/// <summary>Replaces the image and the tooltip of an icon already there.</summary>
internal static bool Modify(IntPtr window, int id, IntPtr icon, string tooltip)
{
NotifyIconData data = Describe(window, id, icon, tooltip);
return Shell_NotifyIcon(NIM_MODIFY, ref data);
}
/// <summary>Takes the icon away. A forgotten icon stays in the tray until hovered.</summary>
internal static void Remove(IntPtr window, int id)
{
var data = new NotifyIconData
{
cbSize = Marshal.SizeOf<NotifyIconData>(),
hWnd = window,
uID = (uint)id,
};
Shell_NotifyIcon(NIM_DELETE, ref data);
}
/// <summary>
/// The icon of the application at the size the tray asks for.
/// </summary>
/// <remarks>
/// The image comes from the executable itself, so the tray shows what the user
/// sees in Explorer. The build puts the icon under the standard resource; should
/// it end up elsewhere, the first icon of the file is taken, and failing that —
/// the icon Windows gives to an application without one. An icon is needed
/// either way: without it the tray shows an empty spot.
/// </remarks>
internal static IntPtr LoadApplicationIcon()
{
int width = GetSystemMetrics(SM_CXSMICON);
int height = GetSystemMetrics(SM_CYSMICON);
IntPtr icon = LoadImage(
GetModuleHandle(null), ApplicationIconResource, IMAGE_ICON, width, height, LR_DEFAULTCOLOR);
if (icon == IntPtr.Zero && Environment.ProcessPath is { Length: > 0 } path)
{
icon = ExtractIconEx(path, 0, out IntPtr large, out IntPtr small, 1) > 0 ? small : IntPtr.Zero;
if (large != IntPtr.Zero)
{
DestroyIcon(large);
}
}
return icon != IntPtr.Zero ? icon : LoadIcon(IntPtr.Zero, ApplicationIconResource);
}
/// <summary>Releases an icon loaded by <see cref="LoadApplicationIcon"/>.</summary>
internal static void ReleaseIcon(IntPtr icon)
{
if (icon != IntPtr.Zero)
{
DestroyIcon(icon);
}
}
/// <summary>The notification the icon has sent: it sits in the low word of lParam.</summary>
internal static int NotificationOf(IntPtr lParam) => (int)(lParam.ToInt64() & 0xFFFF);
/// <summary>
/// Brings the window to the foreground.
/// </summary>
/// <remarks>
/// Windows takes a menu down when its owner window loses the foreground. A tray
/// icon belongs to a window that is never shown, so the foreground has to be
/// asked for by hand — otherwise the menu stays on screen after the user has
/// clicked past it.
/// </remarks>
internal static void BringToForeground(IntPtr window) => SetForegroundWindow(window);
private static NotifyIconData Describe(IntPtr window, int id, IntPtr icon, string tooltip) => new()
{
cbSize = Marshal.SizeOf<NotifyIconData>(),
hWnd = window,
uID = (uint)id,
uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP | NIF_SHOWTIP,
uCallbackMessage = CallbackMessage,
hIcon = icon,
szTip = tooltip,
};
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern bool Shell_NotifyIcon(int dwMessage, ref NotifyIconData lpData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int RegisterWindowMessage(string lpString);
[DllImport("user32.dll")]
private static extern IntPtr LoadImage(IntPtr hInst, IntPtr name, uint type, int cx, int cy, uint fuLoad);
private static IntPtr LoadImage(IntPtr hInst, int resource, uint type, int cx, int cy, uint fuLoad) =>
LoadImage(hInst, new IntPtr(resource), type, cx, cy, fuLoad);
[DllImport("user32.dll")]
private static extern IntPtr LoadIcon(IntPtr hInstance, IntPtr lpIconName);
private static IntPtr LoadIcon(IntPtr hInstance, int resource) =>
LoadIcon(hInstance, new IntPtr(resource));
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int ExtractIconEx(string lpszFile, int nIconIndex,
out IntPtr phiconLarge, out IntPtr phiconSmall, int nIcons);
[DllImport("user32.dll")]
private static extern bool DestroyIcon(IntPtr hIcon);
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern IntPtr GetModuleHandle(string? lpModuleName);
/// <summary>
/// NOTIFYICONDATAW. The whole structure is described even though only its first
/// half is used: Windows reads its size and refuses one it does not know.
/// </summary>
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct NotifyIconData
{
public int cbSize;
public IntPtr hWnd;
public uint uID;
public uint uFlags;
public int uCallbackMessage;
public IntPtr hIcon;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string szTip;
public uint dwState;
public uint dwStateMask;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]
public string szInfo;
/// <summary>A timeout in the older versions and the protocol version here.</summary>
public uint uVersion;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
public string szInfoTitle;
public uint dwInfoFlags;
public Guid guidItem;
public IntPtr hBalloonIcon;
}
}
+6
View File
@@ -61,6 +61,12 @@
<data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Settings</value>
</data>
<data name="TrayMenuSettings" xml:space="preserve">
<value>Settings</value>
</data>
<data name="TrayMenuExit" xml:space="preserve">
<value>Exit</value>
</data>
<data name="SectionInterface" xml:space="preserve">
<value>Interface</value>
</data>
+6
View File
@@ -61,6 +61,12 @@
<data name="SettingsTitle" xml:space="preserve">
<value>CursorLang — Настройки</value>
</data>
<data name="TrayMenuSettings" xml:space="preserve">
<value>Настройки</value>
</data>
<data name="TrayMenuExit" xml:space="preserve">
<value>Выход</value>
</data>
<data name="SectionInterface" xml:space="preserve">
<value>Интерфейс</value>
</data>
+20
View File
@@ -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();
}
+126
View File
@@ -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();
}
}
}
+9 -2
View File
@@ -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;
}
+15 -1
View File
@@ -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);
@@ -72,9 +82,13 @@ public sealed class SingleInstanceGate : IDisposable
_activationRequest = new EventWaitHandle(false, EventResetMode.AutoReset, _activationEventName);
if (!_isOwner)
{
if (showRunningInstance)
{
ForegroundPermissionNative.GrantToAnyProcess();
_activationRequest.Set();
}
return false;
}
+54
View File
@@ -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;
}
}
}
+181
View File
@@ -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;
}
}
+60
View File
@@ -455,6 +455,66 @@
</Style.Triggers>
</Style>
<!-- ======================= Tray menu ======================= -->
<Style TargetType="ContextMenu">
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
<Setter Property="HasDropShadow" Value="False" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ContextMenu">
<Border Background="{DynamicResource Theme.PopupBackground}"
BorderBrush="{DynamicResource Theme.ControlBorder}"
BorderThickness="1"
CornerRadius="4"
Padding="3"
SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle" />
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style TargetType="MenuItem">
<Setter Property="Foreground" Value="{DynamicResource Theme.Foreground}" />
<Setter Property="Padding" Value="12,6" />
<Setter Property="MinWidth" Value="160" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="MenuItem">
<Border x:Name="Border"
Background="Transparent"
CornerRadius="3"
Padding="{TemplateBinding Padding}">
<ContentPresenter ContentSource="Header" VerticalAlignment="Center" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsHighlighted" Value="True">
<Setter TargetName="Border" Property="Background"
Value="{DynamicResource Theme.SelectionBackground}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="{x:Static MenuItem.SeparatorStyleKey}" TargetType="Separator">
<Setter Property="Height" Value="1" />
<Setter Property="Margin" Value="8,4" />
<Setter Property="Background" Value="{DynamicResource Theme.ControlBorder}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Separator">
<Border Background="{TemplateBinding Background}"
HorizontalAlignment="Stretch"
SnapsToDevicePixels="True" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ======================= ToolTip ======================= -->
<Style TargetType="ToolTip">
+6 -1
View File
@@ -12,12 +12,17 @@ namespace CursorLang.Views;
/// </summary>
public partial class MainWindow : Window
{
public MainWindow(SettingsViewModel viewModel, IThemeService theme, MainWindowPlacement placement)
public MainWindow(
SettingsViewModel viewModel,
IThemeService theme,
MainWindowPlacement placement,
MainWindowPresenter presenter)
{
InitializeComponent();
DataContext = viewModel;
theme.Register(this);
placement.Attach(this);
presenter.Attach(this);
}
/// <summary>
+30 -1
View File
@@ -33,13 +33,42 @@ MSIX всегда выполняются в контексте вошедшег
раскладки чужого окна не ограничено, поэтому сама подсказка продолжает работать
везде.
## Трей
Приложение работает в фоне и живёт в области уведомлений. Окно настроек —
гость на экране, а не само приложение: оно показывается после установки и
всякий раз, когда его просят иконка или её меню. Обе кнопки в заголовке окна —
свернуть и закрыть — убирают окно в трей и ничего не завершают; выход из
приложения — пункт «Выход» в меню трея.
Окно скрывается, а не закрывается. Закрытие унесло бы с собой его дескриптор, и
всё, что на окне держится, — тема, место, где его оставили, вычисленная
разметка, — строилось бы заново при каждом показе.
Меню иконки сделано средствами WPF, а не системное: так оно держится темы и
языка, выбранных в настройках, как и остальной интерфейс.
Запущенное самой Windows, приложение не показывает окна вовсе и сразу уходит в
трей — пользователь просил, чтобы оно было на месте при входе в систему, а не
чтобы окно встречало его каждое утро. Такой запуск две сборки распознают
по-разному: запись в реестре у распакованной сборки несёт аргумент `--startup`, а
пакет своей командной строкой не распоряжается, и вместо неё Windows
спрашивают об активации.
Если Windows откажет иконке — в сеансе без рабочего стола области уведомлений
нет, — окно возвращает себе обычные обязанности: показывается при любом запуске,
а его закрытие завершает приложение. Иначе у пользователя осталось бы
приложение, которое он не может ни увидеть, ни закрыть.
## Автозапуск
Автозапуск включается из настроек приложения тем способом, который доступен
сборке. Пакет объявляет его в манифесте как `windows.startupTask`. Сборка,
распакованная в папку, прописывается так, как это всегда делали программы для
рабочего стола, — в `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, и прав
администратора для этого не нужно.
администратора для этого не нужно. Записанная там команда заканчивается на
`--startup`: так приложение отличает запуск, устроенный самой Windows, от
запуска пользователем.
И в том, и в другом случае Windows показывает приложение в разделе Параметры —
Приложения — Автозагрузка; если пользователь выключит его там, приложение уже не
+29 -1
View File
@@ -31,13 +31,41 @@ neither delivers keystrokes to the low-level hook nor accepts the layout change
request, so the hotkey does nothing there. Reading the layout of a foreign window
is not restricted, so the tooltip itself keeps working everywhere.
## The tray
The app works in the background and lives in the notification area. The settings
window is a guest on the screen rather than the app itself: it shows up after the
installation and whenever the icon or its menu is asked for. Both buttons in its
title bar — minimise and close — put the window away into the tray and end nothing;
the way out of the app is the Exit item of the tray menu.
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.
The menu of the icon is a WPF one rather than a system one: that way it keeps to
the theme and the language chosen in the settings, like the rest of the interface.
Started by Windows itself, the app shows no window at all and goes straight to the
tray — the user asked for it to be there when they sign in, not for a window to
greet them every morning. The two builds tell such a launch apart differently: the
registry entry of an unpacked build carries the `--startup` argument, while a
package has no say in its command line, and Windows is asked about the activation
instead.
Should Windows refuse the icon — there is no notification area in a session without
a desktop — the window takes its usual duties back: it shows up whatever the launch
was, and closing it ends the app. The alternative would be an app the user can
neither see nor quit.
## Startup
Startup is switched on from the app's settings by whichever means the build has.
A package declares it in the manifest as a `windows.startupTask`. A build unpacked
into a folder registers itself the way desktop programs always have — under
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, which needs no administrator
rights.
rights. The command written there ends with `--startup`: that is how the app tells
a launch of Windows' own doing from a launch by the user.
Either way Windows lists the app in Settings — Apps — Startup; if the user turns
it off there, the app can no longer turn it back on and says so instead of